/**
 * GitHub Feature Sync - Universal Hierarchy Implementation
 *
 * Architecture:
 * - Feature (FS-XXX) → GitHub Milestone (Container)
 * - User Story (US-XXX) → GitHub Issue with format [FS-XXX][US-YYY] Title
 * - Tasks (T-XXX) → Checkboxes in User Story issue body
 *
 * This implements the Universal Hierarchy architecture for GitHub sync.
 * Creates ONE issue PER user story file from specs/{project}/FS-XXX/us-*.md
 */
import { readdir, readFile, writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import * as path from 'path';
import * as yaml from 'yaml';
import { UserStoryIssueBuilder } from './user-story-issue-builder.js';
import { CompletionCalculator } from './completion-calculator.js';
import { DuplicateDetector } from './duplicate-detector.js';
import { execFileNoThrow } from '../../vendor/utils/execFileNoThrow.js';
import { getGitHubAuthFromProject } from '../../vendor/utils/auth-helpers.js';
import { LockManager } from '../../../../../src/utils/lock-manager.js';
import { normalizeIssueBody } from './github-body-utils.js';
import { ensureLabels, setLabelCacheDir } from './label-cache.js';
import { MilestoneCache } from './milestone-cache.js';
import { getIssueNumberFromMetadata } from './metadata-issue-lookup.js';
export class GitHubFeatureSync {
    constructor(client, specsDir, projectRoot) {
        // Cached default branch for the sync session (one API call per session)
        this.defaultBranch = null;
        this.client = client;
        this.specsDir = specsDir;
        this.projectRoot = projectRoot;
        this.calculator = new CompletionCalculator(projectRoot);
        // Get token from .env for gh CLI passthrough
        this.token = getGitHubAuthFromProject(projectRoot).token;
    }
    /**
     * Detect the default branch from the GitHub API.
     * Caches the result per sync session to avoid repeated API calls.
     * Falls back to 'main' if API call fails.
     */
    async detectDefaultBranch() {
        if (this.defaultBranch) {
            return this.defaultBranch;
        }
        const owner = this.client.getOwner();
        const repo = this.client.getRepo();
        const result = await execFileNoThrow('gh', [
            'api', `repos/${owner}/${repo}`, '--jq', '.default_branch'
        ], { env: this.getGhEnv() });
        if (result.exitCode === 0 && result.stdout.trim()) {
            this.defaultBranch = result.stdout.trim();
        }
        else {
            console.warn(`   ⚠️  Failed to detect default branch, falling back to 'main': ${result.stderr}`);
            this.defaultBranch = 'main';
        }
        return this.defaultBranch;
    }
    /**
     * Get environment object with GH_TOKEN for gh CLI commands.
     * This ensures the token from .env is passed to all gh operations,
     * regardless of `gh auth` status.
     */
    getGhEnv() {
        return this.token
            ? { ...process.env, GH_TOKEN: this.token }
            : process.env;
    }
    /**
     * Get the repo slug (owner/repo) for `-R` flag in gh CLI commands.
     * CRITICAL: All gh issue commands MUST use `-R` to target the correct repo,
     * not the repo inferred from the current working directory.
     */
    getRepoSlug() {
        return `${this.client.getOwner()}/${this.client.getRepo()}`;
    }
    /**
     * Sync Feature folder to GitHub (Milestone + User Story Issues)
     *
     * Process:
     * 1. Create/update GitHub Milestone for Feature
     * 2. Find all us-*.md files across all projects
     * 3. Create/update GitHub Issue for EACH user story
     * 4. Update frontmatter with GitHub issue links
     */
    async syncFeatureToGitHub(featureId, projectName) {
        // FS-609: Rate limit pre-check — abort early if quota is low to prevent 403 errors
        const RATE_LIMIT_THRESHOLD = 250;
        try {
            const rateLimit = await this.client.checkRateLimit();
            if (rateLimit.remaining < RATE_LIMIT_THRESHOLD) {
                console.log(`\n⚠️  GitHub API rate limit low: ${rateLimit.remaining}/${rateLimit.limit} remaining`);
                console.log(`   ⏭️  Skipping sync for ${featureId} — will retry after ${rateLimit.reset.toISOString()}`);
                console.log(`   💡 Run /sw:progress-sync to retry when rate limit resets`);
                return {
                    milestoneNumber: 0,
                    milestoneUrl: '',
                    issuesCreated: 0,
                    issuesUpdated: 0,
                    userStoriesProcessed: 0,
                    rateLimitSkipped: true,
                };
            }
        }
        catch (rateLimitError) {
            // FS-609 edge case fix: fail-closed on network errors.
            // If we can't reach the API to check rate limit, subsequent gh calls will also fail.
            // Abort early instead of cascading failures.
            const msg = rateLimitError instanceof Error ? rateLimitError.message : String(rateLimitError);
            if (msg.includes('ENOTFOUND') || msg.includes('ETIMEDOUT') || msg.includes('ECONNREFUSED') || msg.includes('fetch failed')) {
                console.log(`\n⚠️  GitHub API unreachable: ${msg}`);
                console.log(`   ⏭️  Skipping sync for ${featureId} — network appears down`);
                return {
                    milestoneNumber: 0,
                    milestoneUrl: '',
                    issuesCreated: 0,
                    issuesUpdated: 0,
                    userStoriesProcessed: 0,
                    rateLimitSkipped: true,
                };
            }
            // Non-network errors (e.g., JSON parse failure) — proceed with sync (fail-open)
        }
        // SYNC LOCK: Cross-process file lock prevents concurrent syncs of the same feature+repo
        // Root cause: Two sync paths (task completion + status change) can fire simultaneously
        // Result: Duplicate GitHub comments due to race condition
        const owner = this.client.getOwner();
        const repo = this.client.getRepo();
        const lockDir = path.join(this.projectRoot, '.specweave', 'state', 'locks', `github-sync-${owner}-${repo}`);
        const lock = new LockManager(lockDir, 120);
        const acquired = await lock.acquire();
        if (!acquired) {
            console.log(`\n⏭️  Sync already in progress for ${featureId} (lock held by another process)`);
            console.log(`   💡 This prevents race conditions between task completion and status change syncs`);
            // Return placeholder result (sync was skipped, not failed)
            return {
                milestoneNumber: 0,
                milestoneUrl: '',
                issuesCreated: 0,
                issuesUpdated: 0,
                userStoriesProcessed: 0
            };
        }
        try {
            console.log(`\n🔄 Syncing Feature ${featureId} to GitHub...`);
            // FS-621: Initialize persistent label cache
            const stateDir = path.join(this.projectRoot, '.specweave', 'state');
            setLabelCacheDir(stateDir);
            // 1. Load Feature FEATURE.md
            const featureFolder = await this.findFeatureFolder(featureId, projectName);
            if (!featureFolder) {
                console.log(`   ⚠️  Feature ${featureId} not found in ${this.specsDir} (no living docs and auto-create failed)`);
                console.log(`   💡 Run sw:sync-docs or sw:living-docs to generate living docs first`);
                return {
                    milestoneNumber: 0,
                    milestoneUrl: '',
                    issuesCreated: 0,
                    issuesUpdated: 0,
                    userStoriesProcessed: 0,
                };
            }
            const featurePath = path.join(featureFolder, 'FEATURE.md');
            const featureData = await this.parseFeatureMd(featurePath);
            console.log(`   📦 Feature: ${featureData.title}`);
            console.log(`   📊 Status: ${featureData.status}`);
            // 2. Create or update GitHub Milestone
            let milestoneNumber = featureData.external_tools?.github?.id;
            let milestoneUrl = featureData.external_tools?.github?.url;
            if (!milestoneNumber) {
                console.log(`   🚀 Creating GitHub Milestone...`);
                const milestone = await this.createMilestone(featureData);
                milestoneNumber = milestone.number;
                milestoneUrl = milestone.url;
                console.log(`   ✅ Created Milestone #${milestoneNumber}`);
                // Update FEATURE.md with Milestone ID
                await this.updateFeatureMd(featurePath, {
                    type: 'milestone',
                    id: milestoneNumber,
                    url: milestoneUrl,
                });
            }
            else {
                console.log(`   ♻️  Using existing Milestone #${milestoneNumber}`);
                milestoneUrl = featureData.external_tools?.github?.url || milestoneUrl;
            }
            // 3. Find User Story files (scoped to projectName in cross-project mode)
            const userStories = await this.findUserStories(featureId, projectName);
            console.log(`\n   📝 Found ${userStories.length} User Stories to sync...`);
            // 4. Sync each User Story as GitHub Issue
            let issuesCreated = 0;
            let issuesUpdated = 0;
            // Detect default branch once per sync session
            const detectedBranch = await this.detectDefaultBranch();
            console.log(`   🌿 Default branch: ${detectedBranch}`);
            for (const userStory of userStories) {
                console.log(`\n   🔹 Processing ${userStory.id}: ${userStory.title}`);
                // Build issue content using UserStoryIssueBuilder
                const repoInfo = {
                    owner: this.client.getOwner(),
                    repo: this.client.getRepo(),
                    branch: detectedBranch
                };
                const builder = new UserStoryIssueBuilder(userStory.filePath, this.projectRoot, featureId, repoInfo);
                const issueContent = await builder.buildIssueBody();
                // ✅ FIX: Add status to issue content for sync
                issueContent.status = userStory.status;
                // ✅ DUPLICATE PROTECTION WITH GLOBAL DETECTOR
                // Uses proven 3-phase protection: Detection → Verification → Reflection
                //
                // WHY THIS MATTERS:
                // - Previous implementation had race conditions (--limit 1, eventual consistency)
                // - DuplicateDetector handles all edge cases automatically
                // - Auto-closes duplicates if they slip through
                //
                // @see .specweave/increments/0047-us-task-linkage/reports/DUPLICATE-GITHUB-ISSUES-ROOT-CAUSE.md
                let issueNumber;
                let wasUpdated = false;
                // Check 1: User Story frontmatter has issue number
                if (userStory.existingIssue) {
                    console.log(`      ♻️  Issue #${userStory.existingIssue} exists in frontmatter`);
                    try {
                        // Verify issue still exists on GitHub
                        await this.client.getIssue(userStory.existingIssue);
                        // Issue exists, update it with verification
                        await this.updateUserStoryIssue(userStory.existingIssue, issueContent, userStory.filePath);
                        issuesUpdated++;
                        console.log(`      ✅ Updated Issue #${userStory.existingIssue}`);
                        continue;
                    }
                    catch (err) {
                        // Issue deleted on GitHub, fall through to create new
                        console.log(`      ⚠️  Issue #${userStory.existingIssue} deleted on GitHub, creating new`);
                    }
                }
                // FS-621: Check metadata.json for already-linked issue (skip dup detection)
                try {
                    const incrementsDir = path.join(this.projectRoot, '.specweave', 'increments');
                    const metadataIssueNumber = await getIssueNumberFromMetadata(incrementsDir, featureId, userStory.id);
                    if (metadataIssueNumber) {
                        console.log(`      ⚡ Issue #${metadataIssueNumber} found in metadata (skipping dup detection)`);
                        issueNumber = metadataIssueNumber;
                        // updateUserStoryIssue handles body update with diff optimization
                        await this.updateUserStoryIssue(metadataIssueNumber, issueContent, userStory.filePath);
                        issuesUpdated++;
                        continue;
                    }
                }
                catch (metadataErr) {
                    // Metadata lookup or issue edit failed (e.g., deleted issue) — fall through to createWithProtection
                    console.warn(`      ⚠️  Metadata path failed: ${metadataErr.message}, falling back to dup detection`);
                }
                // Check 2 & 3: Use DuplicateDetector for robust duplicate prevention
                // This handles:
                // - Search with proper limits (not --limit 1)
                // - Post-create verification
                // - Auto-close duplicates
                // - Eventual consistency race conditions
                const titlePattern = `[${featureId}][${userStory.id}]`;
                const milestoneTitle = `${featureData.id}: ${featureData.title}`;
                console.log(`      🛡️  Using DuplicateDetector (pattern: ${titlePattern})`);
                const result = await DuplicateDetector.createWithProtection({
                    title: issueContent.title,
                    body: issueContent.body,
                    titlePattern,
                    incrementId: userStory.id,
                    labels: issueContent.labels,
                    milestone: milestoneTitle,
                    repo: `${this.client.getOwner()}/${this.client.getRepo()}`
                });
                issueNumber = result.issue.number;
                // Log duplicate protection results
                if (result.wasReused) {
                    console.log(`      ♻️  Reused existing issue #${issueNumber} (duplicate prevented!)`);
                    wasUpdated = true;
                }
                else {
                    console.log(`      ✅ Created issue #${issueNumber}`);
                }
                if (result.duplicatesFound > 0) {
                    console.log(`      🛡️  Duplicates detected: ${result.duplicatesFound}, auto-closed: ${result.duplicatesClosed}`);
                }
                // Update User Story frontmatter with issue link
                await this.updateUserStoryFrontmatter(userStory.filePath, issueNumber);
                // v1.0.240 FIX: Backfill increment metadata.json for reconciler compatibility
                // Previously only US frontmatter was updated, leaving metadata.json empty.
                // This caused reconciler and closure flows to miss these issues.
                await this.backfillIncrementMetadata(featureId, userStory.id, issueNumber, milestoneNumber);
                // ✅ CRITICAL FIX (2025-11-24): Check completion for ALL issues (new AND reused)
                // BUG: Previously only checked completion for reused issues, not new ones
                // RESULT: New issues stayed OPEN even if status:complete
                //
                // Now we always call updateUserStoryIssue() which:
                // 1. Calculates ACTUAL completion from [x] checkboxes
                // 2. Closes issue if all ACs and tasks verified complete
                // 3. Updates status labels automatically
                await this.updateUserStoryIssue(issueNumber, issueContent, userStory.filePath);
                // Update completion tracking
                if (result.wasReused) {
                    issuesUpdated++;
                }
                else {
                    issuesCreated++;
                }
            }
            console.log(`\n✅ Feature sync complete!`);
            console.log(`   Milestone: ${milestoneUrl}`);
            console.log(`   User Stories: ${userStories.length}`);
            console.log(`   Issues created: ${issuesCreated}`);
            console.log(`   Issues updated: ${issuesUpdated}`);
            return {
                milestoneNumber: milestoneNumber,
                milestoneUrl: milestoneUrl,
                issuesCreated,
                issuesUpdated,
                userStoriesProcessed: userStories.length,
            };
        }
        finally {
            await lock.release();
        }
    }
    /**
     * Find Feature folder in specs directory.
     * Falls back to auto-creating from increment spec.md if living docs don't exist.
     */
    async findFeatureFolder(featureId, projectName) {
        // v5.0.0+: NO _features folder - features live in project folders
        // In cross-project mode, look in the specific project folder first
        if (projectName) {
            const projectSpecific = path.join(this.specsDir, projectName, featureId);
            if (existsSync(projectSpecific) && existsSync(path.join(projectSpecific, 'FEATURE.md'))) {
                return projectSpecific;
            }
        }
        // Search all project folders for the feature
        const projectFolders = await this.findProjectFolders();
        for (const projectFolder of projectFolders) {
            const featureFolder = path.join(projectFolder, featureId);
            if (existsSync(featureFolder) && existsSync(path.join(featureFolder, 'FEATURE.md'))) {
                return featureFolder;
            }
        }
        // Legacy fallback: Check _features folder (for brownfield migration)
        const legacyFolder = path.join(this.specsDir, '_features', featureId);
        if (existsSync(legacyFolder)) {
            console.log(`   ⚠️  Found feature in legacy _features folder - consider migrating to project folder`);
            return legacyFolder;
        }
        // FALLBACK (v1.0.302): Auto-create feature folder from increment spec.md
        // Most increments never get living docs created, so sync silently fails.
        // This makes sync self-healing by creating minimal living docs on-the-fly.
        console.log(`   ℹ️  Feature folder not found in living docs, attempting auto-create from spec.md...`);
        const created = await this.createFeatureFolderFromSpec(featureId, projectFolders);
        if (created) {
            return created;
        }
        return null;
    }
    /**
     * Find the increment folder for a given feature ID.
     * Converts FS-271 -> finds 0271-xxx-xxx/ in .specweave/increments/
     */
    async findIncrementFolder(featureId) {
        const numMatch = featureId.match(/FS-0*(\d+)E?/i);
        if (!numMatch)
            return null;
        const num = parseInt(numMatch[1], 10);
        const paddedNum = String(num).padStart(4, '0');
        const incrementsDir = path.join(this.projectRoot, '.specweave/increments');
        if (!existsSync(incrementsDir))
            return null;
        const entries = await readdir(incrementsDir);
        const match = entries.find(e => e.startsWith(paddedNum + '-'));
        if (!match)
            return null;
        return path.join(incrementsDir, match);
    }
    /**
     * Auto-create a feature folder (FEATURE.md + us-NNN.md files) from an
     * increment's spec.md. This enables GitHub sync even when the living docs
     * builder hasn't run yet.
     */
    async createFeatureFolderFromSpec(featureId, projectFolders) {
        try {
            const incrementFolder = await this.findIncrementFolder(featureId);
            if (!incrementFolder) {
                console.log(`   ⚠️  No increment folder found for ${featureId}`);
                return null;
            }
            const specPath = path.join(incrementFolder, 'spec.md');
            if (!existsSync(specPath)) {
                console.log(`   ⚠️  No spec.md found in ${path.basename(incrementFolder)}`);
                return null;
            }
            const specContent = await readFile(specPath, 'utf-8');
            // Parse frontmatter (optional — many specs don't have YAML frontmatter)
            const fmMatch = specContent.match(/^---\n([\s\S]*?)\n---/);
            let frontmatter = {};
            if (fmMatch) {
                try {
                    frontmatter = yaml.parse(fmMatch[1]) || {};
                }
                catch {
                    // Invalid YAML — proceed without frontmatter
                }
            }
            const incrementBasename = path.basename(incrementFolder);
            const title = frontmatter.title
                || specContent.match(/^#\s+(.+)/m)?.[1]?.trim()
                || incrementBasename.replace(/^\d+-/, '').replace(/-/g, ' ');
            const status = frontmatter.status || 'active';
            const priority = frontmatter.priority || 'P2';
            const created = frontmatter.created || new Date().toISOString().split('T')[0];
            const incrementId = frontmatter.increment || incrementBasename;
            // Determine target project folder from spec.md user stories or first available
            let targetProjectFolder = projectFolders[0]; // Default: first project folder
            const projectMatch = specContent.match(/\*\*Project\*\*:\s*(\S+)/);
            if (projectMatch) {
                const projectName = projectMatch[1];
                const matchingFolder = projectFolders.find(f => path.basename(f) === projectName);
                if (matchingFolder) {
                    targetProjectFolder = matchingFolder;
                }
            }
            if (!targetProjectFolder) {
                console.log(`   ⚠️  No project folder available for feature creation`);
                return null;
            }
            // Create feature folder
            const featureFolder = path.join(targetProjectFolder, featureId);
            await mkdir(featureFolder, { recursive: true });
            // Parse user stories from spec.md body
            const userStories = this.parseUserStoriesFromSpec(specContent, featureId);
            // Create FEATURE.md
            const featureMd = this.buildFeatureMd(featureId, title, status, priority, created, incrementId, userStories);
            await writeFile(path.join(featureFolder, 'FEATURE.md'), featureMd, 'utf-8');
            // Create us-NNN.md files
            for (const us of userStories) {
                const usFilename = `us-${us.id.replace('US-', '').padStart(3, '0')}-${this.slugify(us.title)}.md`;
                const usMd = this.buildUserStoryMd(us, featureId, incrementId);
                await writeFile(path.join(featureFolder, usFilename), usMd, 'utf-8');
            }
            console.log(`   ✅ Auto-created feature folder with ${userStories.length} user stories`);
            return featureFolder;
        }
        catch (error) {
            console.log(`   ⚠️  Failed to auto-create feature folder: ${error.message}`);
            return null;
        }
    }
    /**
     * Parse user stories from spec.md markdown content.
     */
    parseUserStoriesFromSpec(specContent, featureId) {
        const stories = [];
        // Match ### US-NNN: Title (Priority) sections
        const usRegex = /### (US-\d+):\s*(.+?)(?:\s*\((P\d)\))?\s*\n([\s\S]*?)(?=\n### US-|\n## |\n---\s*\n### US-|$)/g;
        let match;
        while ((match = usRegex.exec(specContent)) !== null) {
            const usId = match[1];
            const rawTitle = match[2].trim();
            const priority = match[3] || 'P2';
            const body = match[4];
            // Skip template placeholders
            if (rawTitle === '[Story Title]')
                continue;
            // Extract project
            const projectMatch = body.match(/\*\*Project\*\*:\s*(\S+)/);
            const project = projectMatch ? projectMatch[1] : 'specweave';
            // Extract story text (As a... I want... So that...)
            const storyMatch = body.match(/\*\*As a\*\*\s+([\s\S]*?)(?=\n\*\*Acceptance Criteria|$)/);
            const storyText = storyMatch ? storyMatch[1].trim() : '';
            // Extract acceptance criteria
            const acs = [];
            const acRegex = /- \[[ x]\] \*\*AC-[^*]+\*\*:\s*(.+)/g;
            let acMatch;
            while ((acMatch = acRegex.exec(body)) !== null) {
                acs.push(acMatch[0]);
            }
            // Determine status from ACs
            const totalAcs = acs.length;
            const completedAcs = acs.filter(ac => ac.startsWith('- [x]')).length;
            let status = 'not-started';
            if (totalAcs > 0 && completedAcs === totalAcs)
                status = 'complete';
            else if (completedAcs > 0)
                status = 'active';
            stories.push({
                id: usId,
                title: rawTitle,
                priority,
                project,
                storyText,
                acceptanceCriteria: acs,
                status,
            });
        }
        return stories;
    }
    /**
     * Build FEATURE.md content matching the living docs format.
     */
    buildFeatureMd(featureId, title, status, priority, created, incrementId, userStories) {
        const now = new Date().toISOString().split('T')[0];
        const mappedStatus = status === 'planned' ? 'planning'
            : status === 'completed' ? 'complete'
                : status === 'active' ? 'active'
                    : 'planning';
        const fm = {
            id: featureId,
            title,
            type: 'feature',
            status: mappedStatus,
            priority,
            created,
            lastUpdated: now,
            tldr: title,
            complexity: 'medium',
            auto_created: true,
        };
        const yamlFm = yaml.stringify(fm);
        let body = `\n# ${title}\n\n## TL;DR\n\n**What**: ${title}\n**Status**: ${mappedStatus} | **Priority**: ${priority}\n**User Stories**: ${userStories.length}\n\n## Overview\n\n${title}\n\n## Implementation History\n\n| Increment | Status |\n|-----------|--------|\n| [${incrementId}](../../../../../increments/${incrementId}/spec.md) | ${mappedStatus} |\n\n## User Stories\n`;
        for (const us of userStories) {
            body += `\n- [${us.id}: ${us.title}](./${us.id.toLowerCase()}.md)`;
        }
        return `---\n${yamlFm}---${body}\n`;
    }
    /**
     * Build us-NNN.md content matching the living docs format.
     */
    buildUserStoryMd(us, featureId, incrementId) {
        const now = new Date().toISOString().split('T')[0];
        const fm = {
            id: us.id,
            feature: featureId,
            title: us.title,
            status: us.status,
            priority: us.priority,
            created: now,
            project: us.project,
        };
        const yamlFm = yaml.stringify(fm);
        let body = `\n# ${us.id}: ${us.title}\n\n**Feature**: [${featureId}](./FEATURE.md)\n\n`;
        if (us.storyText) {
            body += `${us.storyText}\n\n`;
        }
        body += `---\n\n## Acceptance Criteria\n\n`;
        if (us.acceptanceCriteria.length > 0) {
            body += us.acceptanceCriteria.join('\n') + '\n';
        }
        else {
            body += `- [ ] **AC-${us.id.replace('US-', 'US')}-01**: Pending specification\n`;
        }
        body += `\n---\n\n## Implementation\n\n**Increment**: [${incrementId}](../../../../../increments/${incrementId}/spec.md)\n`;
        return `---\n${yamlFm}---${body}\n`;
    }
    /**
     * Convert a title to a URL-safe slug.
     */
    slugify(text) {
        return text
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, '-')
            .replace(/^-+|-+$/g, '')
            .substring(0, 60);
    }
    /**
     * Backfill increment metadata.json with GitHub issue reference (v1.0.240)
     *
     * Writes in BOTH formats:
     * - OLD format (metadata.github.issues[]) for backward compatibility
     * - NEW format (metadata.externalLinks.github) for reconciler/closure flows
     * Non-blocking — errors are logged but don't halt sync.
     */
    async backfillIncrementMetadata(featureId, userStoryId, issueNumber, milestoneNumber) {
        try {
            // Derive increment ID from feature ID (reverse of deriveFeatureId)
            const featureNumMatch = featureId.match(/FS-0*(\d+)E?/i);
            if (!featureNumMatch)
                return;
            const num = parseInt(featureNumMatch[1], 10);
            const paddedNum = String(num).padStart(4, '0');
            // Find the increment directory
            const incrementsDir = path.join(this.projectRoot, '.specweave/increments');
            if (!existsSync(incrementsDir))
                return;
            const entries = await readdir(incrementsDir);
            const match = entries.find(e => e.startsWith(paddedNum + '-'));
            if (!match)
                return;
            const metadataPath = path.join(incrementsDir, match, 'metadata.json');
            if (!existsSync(metadataPath))
                return;
            const metadata = JSON.parse(await readFile(metadataPath, 'utf-8'));
            const issueUrl = `https://github.com/${this.client.getOwner()}/${this.client.getRepo()}/issues/${issueNumber}`;
            let changed = false;
            // Write in OLD format for backward compatibility
            if (!metadata.github)
                metadata.github = {};
            if (!metadata.github.issues)
                metadata.github.issues = [];
            const existsOld = metadata.github.issues.some((i) => i.userStory === userStoryId);
            if (!existsOld) {
                metadata.github.issues.push({
                    userStory: userStoryId,
                    number: issueNumber,
                    url: issueUrl,
                    createdAt: new Date().toISOString(),
                });
                metadata.github.lastSync = new Date().toISOString();
                changed = true;
            }
            // Write in NEW format (externalLinks.github) for reconciler/closure flows
            if (!metadata.externalLinks)
                metadata.externalLinks = {};
            if (!metadata.externalLinks.github)
                metadata.externalLinks.github = {};
            if (!metadata.externalLinks.github.issues)
                metadata.externalLinks.github.issues = {};
            const existsNew = metadata.externalLinks.github.issues[userStoryId];
            if (!existsNew) {
                metadata.externalLinks.github.issues[userStoryId] = {
                    issueNumber,
                    issueUrl,
                    status: 'active',
                };
                changed = true;
            }
            // Always set milestone and syncedAt on the new format
            if (milestoneNumber && metadata.externalLinks.github.milestone !== milestoneNumber) {
                metadata.externalLinks.github.milestone = milestoneNumber;
                changed = true;
            }
            if (changed) {
                metadata.externalLinks.github.syncedAt = new Date().toISOString();
                await writeFile(metadataPath, JSON.stringify(metadata, null, 2) + '\n', 'utf-8');
                console.log(`      📝 Backfilled metadata.json for ${userStoryId} (dual-format)`);
            }
        }
        catch (error) {
            // Non-blocking
            console.warn(`      ⚠️ Metadata backfill failed: ${error.message}`);
        }
    }
    /**
     * Parse FEATURE.md frontmatter
     */
    async parseFeatureMd(featurePath) {
        const content = await readFile(featurePath, 'utf-8');
        const match = content.match(/^---\n([\s\S]*?)\n---/);
        if (!match) {
            throw new Error(`${featurePath}: Missing YAML frontmatter`);
        }
        return yaml.parse(match[1]);
    }
    /**
     * Find all User Story files for this feature across all projects
     */
    async findUserStories(featureId, projectName) {
        const userStories = [];
        // In cross-project mode, only look in the target project's folder
        let projectFolders;
        if (projectName) {
            const projectSpecific = path.join(this.specsDir, projectName);
            projectFolders = existsSync(projectSpecific) ? [projectSpecific] : [];
        }
        else {
            projectFolders = await this.findProjectFolders();
        }
        for (const projectFolder of projectFolders) {
            const featureSpecsFolder = path.join(projectFolder, featureId);
            if (!existsSync(featureSpecsFolder)) {
                continue; // Feature not present in this project
            }
            // Read all us-*.md files
            const files = await readdir(featureSpecsFolder);
            const usFiles = files.filter((f) => f.startsWith('us-') && f.endsWith('.md'));
            for (const file of usFiles.sort()) {
                const filePath = path.join(featureSpecsFolder, file);
                const content = await readFile(filePath, 'utf-8');
                const match = content.match(/^---\n([\s\S]*?)\n---/);
                if (!match) {
                    console.warn(`   ⚠️  ${file}: Missing frontmatter, skipping`);
                    continue;
                }
                const frontmatter = yaml.parse(match[1]);
                const projectName = path.basename(projectFolder);
                userStories.push({
                    id: frontmatter.id || file.match(/us-(\d+)/)?.[0]?.toUpperCase() || 'UNKNOWN',
                    title: frontmatter.title || 'Untitled User Story',
                    filePath,
                    project: projectName,
                    status: frontmatter.status || 'not-started',
                    existingIssue: frontmatter.external?.github?.issue || null,
                });
            }
        }
        return userStories;
    }
    /**
     * Find all project folders (default, backend, frontend, etc.)
     */
    async findProjectFolders() {
        const folders = [];
        const specsRoot = this.specsDir;
        // Read all directories in specs root
        const entries = await readdir(specsRoot, { withFileTypes: true });
        for (const entry of entries) {
            if (entry.isDirectory() && !entry.name.startsWith('_')) {
                folders.push(path.join(specsRoot, entry.name));
            }
        }
        return folders;
    }
    /**
     * Create GitHub Milestone for Feature (with duplicate detection + session cache)
     */
    async createMilestone(featureData) {
        const title = `${featureData.id}: ${featureData.title}`;
        const owner = this.client.getOwner();
        const repo = this.client.getRepo();
        const description = `Feature ${featureData.id}\n\nStatus: ${featureData.status}\nCreated: ${featureData.created}`;
        return MilestoneCache.getOrCreate(owner, repo, title, description, this.getGhEnv());
    }
    /**
     * Create GitHub Issue for User Story with AC/Task Verification
     *
     * ✅ VERIFICATION GATE FIX:
     * - Verifies actual completion before closing
     * - Prevents premature closure on creation
     */
    async createUserStoryIssue(issueContent, milestoneTitle, userStoryPath) {
        // Step 0: Ensure all required labels exist in the target repo (session-cached)
        const repoSlug = `${this.client.getOwner()}/${this.client.getRepo()}`;
        await ensureLabels(repoSlug, issueContent.labels, this.getGhEnv());
        // Step 1: Create issue (always open initially - gh CLI limitation)
        const result = await execFileNoThrow('gh', [
            'issue',
            'create',
            '--repo',
            repoSlug,
            '--title',
            issueContent.title,
            '--body',
            issueContent.body,
            '--milestone',
            milestoneTitle,
            ...issueContent.labels.flatMap((label) => ['--label', label]),
        ], { env: this.getGhEnv() });
        if (result.exitCode !== 0) {
            throw new Error(`Failed to create GitHub Issue: ${result.stderr || result.stdout}`);
        }
        // Parse issue number from output
        // Format: "https://github.com/owner/repo/issues/123"
        const match = result.stdout.match(/issues\/(\d+)/);
        if (!match) {
            throw new Error(`Failed to parse issue number from: ${result.stdout}`);
        }
        const issueNumber = parseInt(match[1], 10);
        // Step 2: VERIFICATION GATE - Close only if ACs/tasks verified
        const completion = await this.calculator.calculateCompletion(userStoryPath);
        if (completion.overallComplete) {
            // ✅ SAFE TO CLOSE - All ACs and tasks verified [x]
            await execFileNoThrow('gh', [
                'issue',
                'close',
                issueNumber.toString(),
                '--comment',
                this.calculator.buildCompletionComment(completion),
                '-R',
                repoSlug,
            ], { env: this.getGhEnv() });
            console.log(`      ✅ Created and verified complete: ${completion.acsCompleted}/${completion.acsTotal} ACs, ${completion.tasksCompleted}/${completion.tasksTotal} tasks`);
        }
        else {
            // ⚠️ INCOMPLETE - Leave open with progress comment (with deduplication)
            // Note: For newly created issues, this is the first comment so deduplication
            // will likely pass through, but the logic is here for consistency
            await this.postProgressCommentIfChanged(issueNumber, completion);
        }
        return issueNumber;
    }
    /**
     * Update existing GitHub Issue for User Story with AC/Task Verification
     *
     * ✅ VERIFICATION GATE FIX:
     * - OLD: Closed based on frontmatter `status: complete`
     * - NEW: Closes ONLY if all ACs and tasks are [x]
     *
     * This prevents Issue #574 type bugs (premature closure)
     */
    async updateUserStoryIssue(issueNumber, issueContent, userStoryPath) {
        // Body diff check: skip `gh issue edit` when body is unchanged (FS-587)
        const repoSlug = this.getRepoSlug();
        let shouldEdit = true;
        try {
            const viewResult = await execFileNoThrow('gh', [
                'issue', 'view', issueNumber.toString(),
                '--json', 'body', '--jq', '.body',
                '-R', repoSlug,
            ], { env: this.getGhEnv() });
            if (viewResult.exitCode === 0 && viewResult.stdout) {
                const currentNormalized = normalizeIssueBody(viewResult.stdout);
                const newNormalized = normalizeIssueBody(issueContent.body);
                if (currentNormalized === newNormalized) {
                    shouldEdit = false;
                    console.log(`      ⏭️  Body unchanged, skipping gh issue edit for #${issueNumber}`);
                }
            }
        }
        catch {
            // 404 or other error fetching current body — proceed with edit
        }
        if (shouldEdit) {
            await execFileNoThrow('gh', [
                'issue',
                'edit',
                issueNumber.toString(),
                '--title',
                issueContent.title,
                '--body',
                issueContent.body,
                '-R',
                repoSlug,
            ], { env: this.getGhEnv() });
        }
        // ✅ VERIFICATION GATE: Calculate ACTUAL completion from checkboxes
        const completion = await this.calculator.calculateCompletion(userStoryPath);
        // FS-612: Fetch issue state + last comment in a SINGLE GraphQL call
        // (replaces sequential getIssue() + getLastComment() — saves 1 API call per US)
        const { issue: issueData, lastComment } = await this.client.getIssueWithLastComment(issueNumber);
        const currentlyClosed = issueData.state === 'closed';
        // FS-609: Track whether this method mutated the issue (close/reopen/comment).
        // If mutations happened, cached data is stale and must not be passed downstream.
        let mutatedIssue = false;
        // DECISION LOGIC: Close/Reopen/Update based on VERIFIED completion
        if (completion.overallComplete) {
            // ✅ SAFE TO CLOSE - All ACs and tasks verified [x]
            if (!currentlyClosed) {
                // Idempotency check: skip completion comment if already posted by another sync path
                const commentAlreadyPosted = lastComment?.body?.includes('✅ User Story Complete');
                if (commentAlreadyPosted) {
                    // Close without duplicate comment
                    await execFileNoThrow('gh', [
                        'issue',
                        'close',
                        issueNumber.toString(),
                        '-R',
                        repoSlug,
                    ], { env: this.getGhEnv() });
                    console.log(`      ✅ Verified complete (comment already posted): ${completion.acsCompleted}/${completion.acsTotal} ACs, ${completion.tasksCompleted}/${completion.tasksTotal} tasks`);
                }
                else {
                    await execFileNoThrow('gh', [
                        'issue',
                        'close',
                        issueNumber.toString(),
                        '--comment',
                        this.calculator.buildCompletionComment(completion),
                        '-R',
                        repoSlug,
                    ], { env: this.getGhEnv() });
                    console.log(`      ✅ Verified complete: ${completion.acsCompleted}/${completion.acsTotal} ACs, ${completion.tasksCompleted}/${completion.tasksTotal} tasks`);
                }
                mutatedIssue = true;
            }
        }
        else {
            // ⚠️ INCOMPLETE - Keep open or reopen if needed
            if (currentlyClosed) {
                // Issue was closed prematurely - REOPEN
                await execFileNoThrow('gh', [
                    'issue',
                    'reopen',
                    issueNumber.toString(),
                    '--comment',
                    this.calculator.buildReopenComment(completion, 'Work verification failed'),
                    '-R',
                    repoSlug,
                ], { env: this.getGhEnv() });
                console.log(`      ⚠️ Reopened: ${completion.blockingAcs.length + completion.blockingTasks.length} items incomplete`);
                mutatedIssue = true;
            }
            else {
                // Update progress comment (with deduplication)
                // FS-609: Pass cached lastComment to avoid re-fetching
                await this.postProgressCommentIfChanged(issueNumber, completion, lastComment);
                // Note: postProgressCommentIfChanged may or may not post — if it did, cache is stale
                // but updateStatusLabels only uses lastComment for completion check, and if we're
                // in the incomplete+open branch, the completion comment check won't trigger anyway.
            }
        }
        // FS-609: Pass cached data ONLY if no mutations happened.
        // If we closed/reopened/commented, the cached state is stale — let updateStatusLabels re-fetch.
        await this.updateStatusLabels(issueNumber, completion, mutatedIssue ? undefined : issueData, mutatedIssue ? undefined : lastComment);
    }
    /**
     * Update status labels on GitHub issue based on completion state
     *
     * SMART LABEL MANAGEMENT:
     * - Only manages status:* labels (status:not_started, status:in-progress, status:completed)
     * - Preserves all other labels (priority, type, custom labels)
     * - Ensures exactly one status label is present
     */
    async updateStatusLabels(issueNumber, completion, cachedIssueData, cachedLastComment) {
        try {
            // FS-609: Use cached issue data if provided, otherwise fetch (backward compat)
            const issueData = cachedIssueData || await this.client.getIssue(issueNumber);
            const currentLabels = issueData.labels || [];
            // Separate status labels from other labels
            const statusLabels = currentLabels.filter((label) => label.startsWith('status:'));
            const otherLabels = currentLabels.filter((label) => !label.startsWith('status:'));
            // Determine correct status label based on completion
            // NOTE: Label names must match repository labels exactly
            let newStatusLabel;
            if (completion.overallComplete) {
                newStatusLabel = 'status:complete'; // Repository uses "complete" not "completed"
            }
            else if (completion.acsPercentage > 0 || completion.tasksPercentage > 0) {
                newStatusLabel = 'status:active'; // Repository uses "active" not "in-progress"
            }
            else if (
            // v0.35.1 FIX: For external-origin USs without ACs/tasks, use frontmatter status
            // This fixes issue #889 where external USs always showed "not_started"
            (completion.acsTotal === 0 || completion.acsTotal === undefined) &&
                (completion.tasksTotal === 0 || completion.tasksTotal === undefined) &&
                completion.frontmatterStatus) {
                // Map frontmatter status to GitHub label
                switch (completion.frontmatterStatus) {
                    case 'complete':
                    case 'completed':
                        newStatusLabel = 'status:complete';
                        break;
                    case 'active':
                    case 'in-progress':
                        newStatusLabel = 'status:active';
                        break;
                    case 'planning':
                    case 'not-started':
                    default:
                        newStatusLabel = 'status:not_started';
                }
                console.log(`      ℹ️  Using frontmatter status (no ACs/tasks): ${completion.frontmatterStatus} → ${newStatusLabel}`);
            }
            else {
                newStatusLabel = 'status:not_started';
            }
            // Check if update needed
            const needsUpdate = statusLabels.length !== 1 || !statusLabels.includes(newStatusLabel);
            if (!needsUpdate) {
                return; // Status label already correct
            }
            // Update labels using gh CLI
            // Strategy: Remove old status labels first (if any), then add new one
            // Step 1: Remove old status labels (only if they exist)
            if (statusLabels.length > 0) {
                await execFileNoThrow('gh', [
                    'issue',
                    'edit',
                    issueNumber.toString(),
                    '-R', this.getRepoSlug(),
                    '--remove-label',
                    ...statusLabels,
                ], { env: this.getGhEnv() });
            }
            // Step 2: Add new status label
            const result = await execFileNoThrow('gh', [
                'issue',
                'edit',
                issueNumber.toString(),
                '-R', this.getRepoSlug(),
                '--add-label',
                newStatusLabel,
            ], { env: this.getGhEnv() });
            if (result.exitCode === 0) {
                console.log(`      🏷️  Updated label: ${newStatusLabel}`);
            }
            else {
                console.warn(`      ⚠️  Failed to add label ${newStatusLabel}: ${result.stderr}`);
            }
            // Step 3: Auto-close issue when status:complete and issue still OPEN
            // This ensures closure happens atomically with the label update,
            // preventing issues like #1198 where label is applied but issue stays open
            if (newStatusLabel === 'status:complete' && issueData.state.toLowerCase() !== 'closed') {
                try {
                    // FS-609: Use cached issue data — updateUserStoryIssue already closed if needed,
                    // so if we reach here with status:complete, the issue state from cache is reliable
                    // within the same sync operation. No need to re-fetch.
                    const freshState = issueData.state.toLowerCase();
                    if (freshState === 'closed') {
                        console.log(`      ⏭️  Issue #${issueNumber} already closed (skipping duplicate close)`);
                    }
                    else {
                        // FS-609: Use cached lastComment if available, otherwise fetch
                        const lastComment = cachedLastComment || await this.client.getLastComment(issueNumber);
                        if (lastComment?.body?.includes('✅ User Story Complete')) {
                            // Comment already exists — close without posting duplicate
                            await execFileNoThrow('gh', [
                                'issue',
                                'close',
                                issueNumber.toString(),
                                '-R', this.getRepoSlug(),
                            ], { env: this.getGhEnv() });
                            console.log(`      ✅ Auto-closed issue #${issueNumber} (comment already posted)`);
                        }
                        else {
                            const completionComment = this.calculator.buildCompletionComment(completion);
                            await execFileNoThrow('gh', [
                                'issue',
                                'close',
                                issueNumber.toString(),
                                '-R', this.getRepoSlug(),
                                '--comment',
                                completionComment,
                            ], { env: this.getGhEnv() });
                            console.log(`      ✅ Auto-closed issue #${issueNumber} (status:complete)`);
                        }
                    }
                }
                catch (closeError) {
                    // Non-blocking: close failure shouldn't break sync
                    console.warn(`      ⚠️  Failed to auto-close issue #${issueNumber}: ${closeError.message}`);
                }
            }
        }
        catch (error) {
            // Non-blocking: Label update failure shouldn't break sync
            console.warn(`      ⚠️  Failed to update status labels: ${error.message}`);
        }
    }
    /**
     * Post progress comment only if it differs from the last comment
     *
     * DEDUPLICATION FIX (2025-11-24):
     * - Prevents posting identical consecutive comments
     * - Fetches last comment from issue
     * - Compares content (ignoring timestamps)
     * - Only posts if progress has changed
     *
     * Root Cause: updateUserStoryIssue() was posting progress comments on EVERY sync,
     * even when progress hadn't changed, causing 4+ duplicate comments.
     *
     * @param issueNumber - GitHub issue number
     * @param completion - Completion status with AC/task metrics
     */
    async postProgressCommentIfChanged(issueNumber, completion, cachedLastComment) {
        try {
            // FS-609: Use cached last comment if available, otherwise fetch via client
            const repoSlug = this.getRepoSlug();
            let lastCommentBody = '';
            if (cachedLastComment) {
                lastCommentBody = cachedLastComment.body || '';
            }
            else {
                const fetchedComment = await this.client.getLastComment(issueNumber);
                lastCommentBody = fetchedComment?.body || '';
            }
            // 2. Build new progress comment
            const newCommentBody = this.calculator.buildProgressComment(completion);
            // 3. Normalize both comments for comparison (remove timestamps, whitespace differences)
            const normalizeComment = (text) => {
                return text
                    .replace(/🤖 Auto-updated by SpecWeave AC Completion Gate/g, '')
                    .replace(/\s+/g, ' ')
                    .trim();
            };
            const normalizedLast = normalizeComment(lastCommentBody);
            const normalizedNew = normalizeComment(newCommentBody);
            // 4. Check if comments are identical (ignoring formatting differences)
            if (normalizedLast === normalizedNew) {
                console.log(`      ⏭️  Progress unchanged (${completion.acsPercentage.toFixed(0)}% ACs, ${completion.tasksPercentage.toFixed(0)}% tasks) - skipping duplicate comment`);
                return;
            }
            // 5. Post new comment only if progress has changed
            await execFileNoThrow('gh', [
                'issue',
                'comment',
                issueNumber.toString(),
                '-R', repoSlug,
                '--body',
                newCommentBody,
            ], { env: this.getGhEnv() });
            console.log(`      📊 Progress: ${completion.acsPercentage.toFixed(0)}% ACs, ${completion.tasksPercentage.toFixed(0)}% tasks (updated)`);
        }
        catch (error) {
            // Non-blocking: Log error but don't break sync
            console.error(`      ⚠️  Failed to check/post progress comment: ${error.message}`);
        }
    }
    /**
     * Update FEATURE.md with GitHub Milestone link
     */
    async updateFeatureMd(featurePath, milestone) {
        const content = await readFile(featurePath, 'utf-8');
        const match = content.match(/^---\n([\s\S]*?)\n---/);
        if (!match) {
            throw new Error(`${featurePath}: Missing YAML frontmatter`);
        }
        const frontmatter = yaml.parse(match[1]);
        // Update external_tools.github
        if (!frontmatter.external_tools) {
            frontmatter.external_tools = {};
        }
        frontmatter.external_tools.github = milestone;
        // Rebuild content
        const newFrontmatter = yaml.stringify(frontmatter);
        const bodyContent = content.slice(match[0].length);
        const newContent = `---\n${newFrontmatter}---${bodyContent}`;
        await writeFile(featurePath, newContent, 'utf-8');
    }
    /**
     * Update User Story frontmatter with GitHub issue link
     */
    async updateUserStoryFrontmatter(userStoryPath, issueNumber) {
        const content = await readFile(userStoryPath, 'utf-8');
        const match = content.match(/^---\n([\s\S]*?)\n---/);
        if (!match) {
            throw new Error(`${userStoryPath}: Missing YAML frontmatter`);
        }
        const frontmatter = yaml.parse(match[1]);
        // Update external_tools.github (consistent with JIRA/ADO write-back)
        if (!frontmatter.external_tools) {
            frontmatter.external_tools = {};
        }
        if (!frontmatter.external_tools.github) {
            frontmatter.external_tools.github = {};
        }
        frontmatter.external_tools.github.issue = issueNumber;
        frontmatter.external_tools.github.url = `https://github.com/${this.client.getOwner()}/${this.client.getRepo()}/issues/${issueNumber}`;
        // Also keep legacy external.github for backwards compatibility
        if (!frontmatter.external) {
            frontmatter.external = {};
        }
        if (!frontmatter.external.github) {
            frontmatter.external.github = {};
        }
        frontmatter.external.github.issue = issueNumber;
        frontmatter.external.github.url = frontmatter.external_tools.github.url;
        // Rebuild content
        const newFrontmatter = yaml.stringify(frontmatter);
        const bodyContent = content.slice(match[0].length);
        const newContent = `---\n${newFrontmatter}---${bodyContent}`;
        await writeFile(userStoryPath, newContent, 'utf-8');
    }
}
//# sourceMappingURL=github-feature-sync.js.map