/**
 * Merge Steward Service
 *
 * This service implements the auto-merge functionality for the orchestration system.
 * The Merge Steward detects completed tasks and merges their branches.
 *
 * Key features:
 * - Detect tasks with mergeStatus 'pending'
 * - Run tests on the task's branch
 * - Auto-merge if tests pass
 * - Create fix tasks if tests fail
 * - Handle merge conflicts
 * - Clean up worktrees after successful merge
 *
 * TB-O21: Merge Steward Auto-Merge
 *
 * @module
 */
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { createTimestamp, TaskStatus, createTask } from '@stoneforge/core';
import { updateOrchestratorTaskMeta, getOrchestratorTaskMeta, } from '../types/index.js';
import { mergeBranch, syncLocalBranch, hasRemote, detectTargetBranch } from '../git/merge.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('merge-steward');
const execAsync = promisify(exec);
// ============================================================================
// Types
// ============================================================================
/**
 * Error thrown when an optimistic locking conflict is detected during
 * a merge status transition. This indicates another steward instance
 * has already claimed the task.
 */
export class MergeStatusConflictError extends Error {
    expectedStatus;
    actualStatus;
    taskId;
    constructor(taskId, expectedStatus, actualStatus) {
        super(`Merge status conflict on task ${taskId}: expected '${expectedStatus}' but found '${actualStatus ?? 'undefined'}'`);
        this.name = 'MergeStatusConflictError';
        this.taskId = taskId;
        this.expectedStatus = expectedStatus;
        this.actualStatus = actualStatus;
    }
}
// ============================================================================
// Merge Steward Service Implementation
// ============================================================================
/**
 * Default configuration values
 */
const DEFAULT_CONFIG = {
    testCommand: 'npm test',
    testTimeoutMs: 5 * 60 * 1000, // 5 minutes
    autoMerge: true,
    autoCleanup: true,
    deleteBranchAfterMerge: true,
    mergeStrategy: 'squash',
    autoPushAfterMerge: true,
};
/**
 * Implementation of the Merge Steward Service.
 */
export class MergeStewardServiceImpl {
    api;
    config;
    taskAssignment;
    dispatchService;
    worktreeManager;
    agentRegistry;
    operationLog;
    targetBranch;
    constructor(api, taskAssignment, dispatchService, agentRegistry, config, worktreeManager, operationLog) {
        this.api = api;
        this.taskAssignment = taskAssignment;
        this.dispatchService = dispatchService;
        this.agentRegistry = agentRegistry;
        this.worktreeManager = worktreeManager;
        this.operationLog = operationLog;
        this.config = {
            ...DEFAULT_CONFIG,
            ...config,
        };
    }
    // ----------------------------------------
    // Task Discovery
    // ----------------------------------------
    async getTasksAwaitingMerge() {
        // Query for tasks in REVIEW status with pending/testing merge status
        // TaskAssignmentService.getTasksAwaitingMerge() now handles this correctly
        return this.taskAssignment.getTasksAwaitingMerge();
    }
    // ----------------------------------------
    // Merge Processing
    // ----------------------------------------
    async processTask(taskId, options = {}) {
        const processedAt = createTimestamp();
        try {
            // 1. Get and validate task
            const task = await this.api.get(taskId);
            if (!task) {
                return {
                    taskId,
                    status: 'failed',
                    merged: false,
                    error: `Task not found: ${taskId}`,
                    processedAt,
                };
            }
            const orchestratorMeta = getOrchestratorTaskMeta(task.metadata);
            if (!orchestratorMeta?.branch) {
                return {
                    taskId,
                    status: 'failed',
                    merged: false,
                    error: 'Task has no branch associated',
                    processedAt,
                };
            }
            // Early exit: Skip if task is already closed and merged
            // This prevents re-processing of already-merged tasks
            if (task.status === TaskStatus.CLOSED && orchestratorMeta.mergeStatus === 'merged') {
                logger.info(`Task ${taskId} is already closed and merged, skipping`);
                return {
                    taskId,
                    status: 'merged',
                    merged: true,
                    processedAt,
                };
            }
            // 2a. Check if the branch has any commits ahead of the target.
            // If the branch is already fully merged (zero commits ahead), there's
            // nothing to test or merge — mark as not_applicable and close the task.
            const branchHasCommits = await this.branchHasCommitsAhead(orchestratorMeta.branch);
            if (!branchHasCommits) {
                logger.info(`Task ${taskId} branch "${orchestratorMeta.branch}" has zero commits ahead of target — already merged`);
                await this.updateMergeStatus(taskId, 'not_applicable');
                return {
                    taskId,
                    status: 'not_applicable',
                    merged: false,
                    processedAt,
                };
            }
            // 2. Run tests (unless skipped)
            let testResult;
            if (!options.skipTests) {
                // Optimistic lock: only transition pending → testing if still pending.
                // If another steward already claimed this task, skip it.
                try {
                    await this.updateMergeStatus(taskId, 'testing', undefined, 'pending');
                }
                catch (error) {
                    if (error instanceof MergeStatusConflictError) {
                        logger.info(`Task ${taskId} skipped: another steward already claimed it (expected 'pending', found '${error.actualStatus}')`);
                        return {
                            taskId,
                            status: 'skipped',
                            merged: false,
                            processedAt,
                        };
                    }
                    throw error;
                }
                const testRunResult = await this.runTests(taskId);
                testResult = testRunResult.testResult;
                if (!testRunResult.passed) {
                    // Tests failed - create fix task
                    this.operationLog?.write('warn', 'merge', `Tests failed for task ${taskId}`, { taskId, exitCode: testRunResult.exitCode, durationMs: testRunResult.durationMs });
                    await this.updateMergeStatus(taskId, 'test_failed', { testResult });
                    const fixTaskId = await this.createFixTask(taskId, {
                        type: 'test_failure',
                        errorDetails: testRunResult.output.substring(0, 2000), // Limit size
                    });
                    return {
                        taskId,
                        status: 'test_failed',
                        merged: false,
                        testResult,
                        fixTaskId,
                        processedAt,
                    };
                }
            }
            // 3. Attempt merge (unless auto-merge disabled)
            if (!this.config.autoMerge && !options.forceMerge) {
                // Just update status to indicate tests passed
                await this.updateMergeStatus(taskId, 'pending', { testResult });
                return {
                    taskId,
                    status: 'pending',
                    merged: false,
                    testResult,
                    processedAt,
                };
            }
            // When tests were skipped, use optimistic lock for pending → merging
            // to prevent two stewards from racing into the merge phase.
            if (options.skipTests) {
                try {
                    await this.updateMergeStatus(taskId, 'merging', undefined, 'pending');
                }
                catch (error) {
                    if (error instanceof MergeStatusConflictError) {
                        logger.info(`Task ${taskId} skipped: another steward already claimed it for merging (expected 'pending', found '${error.actualStatus}')`);
                        return {
                            taskId,
                            status: 'skipped',
                            merged: false,
                            processedAt,
                        };
                    }
                    throw error;
                }
            }
            else {
                await this.updateMergeStatus(taskId, 'merging');
            }
            const mergeResult = await this.attemptMerge(taskId, options.mergeCommitMessage);
            // Handle already-merged result from mergeBranch() — secondary safety net
            // in case the early branchHasCommitsAhead check didn't trigger (e.g. race)
            if (mergeResult.alreadyMerged) {
                logger.info(`Task ${taskId} branch already merged (detected during merge attempt)`);
                await this.updateMergeStatus(taskId, 'not_applicable');
                return {
                    taskId,
                    status: 'not_applicable',
                    merged: false,
                    testResult,
                    processedAt,
                };
            }
            if (!mergeResult.success) {
                if (mergeResult.hasConflict) {
                    // Merge conflict - create fix task
                    this.operationLog?.write('warn', 'merge', `Merge conflict for task ${taskId}`, { taskId, conflictFiles: mergeResult.conflictFiles });
                    await this.updateMergeStatus(taskId, 'conflict', {
                        failureReason: `Merge conflict in: ${mergeResult.conflictFiles?.join(', ') ?? 'unknown files'}`,
                    });
                    const fixTaskId = await this.createFixTask(taskId, {
                        type: 'merge_conflict',
                        errorDetails: mergeResult.error ?? 'Merge conflict detected',
                        affectedFiles: mergeResult.conflictFiles,
                    });
                    return {
                        taskId,
                        status: 'conflict',
                        merged: false,
                        testResult,
                        fixTaskId,
                        error: mergeResult.error,
                        processedAt,
                    };
                }
                else {
                    // Other merge failure
                    this.operationLog?.write('error', 'merge', `Merge failed for task ${taskId}: ${mergeResult.error}`, { taskId });
                    await this.updateMergeStatus(taskId, 'failed', {
                        failureReason: mergeResult.error,
                    });
                    return {
                        taskId,
                        status: 'failed',
                        merged: false,
                        testResult,
                        error: mergeResult.error,
                        processedAt,
                    };
                }
            }
            // 4. Merge succeeded - update status and cleanup
            this.operationLog?.write('info', 'merge', `Task ${taskId} merged successfully`, { taskId, commitHash: mergeResult.commitHash });
            await this.updateMergeStatus(taskId, 'merged', { testResult });
            // 5. Cleanup worktree if auto-cleanup enabled
            if (this.config.autoCleanup) {
                await this.cleanupAfterMerge(taskId, this.config.deleteBranchAfterMerge);
            }
            // 6. Sync local target branch (best-effort, after all bookkeeping)
            const targetBranch = await this.getTargetBranch();
            const remoteExists = await hasRemote(this.config.workspaceRoot);
            if (remoteExists) {
                try {
                    await execAsync('git fetch origin', { cwd: this.config.workspaceRoot, encoding: 'utf8' });
                }
                catch { /* best-effort */ }
                await syncLocalBranch(this.config.workspaceRoot, targetBranch);
            }
            // In local-only mode, syncLocalBranch is handled by mergeBranch() itself
            return {
                taskId,
                status: 'merged',
                merged: true,
                testResult,
                mergeCommitHash: mergeResult.commitHash,
                processedAt,
            };
        }
        catch (error) {
            const errorMessage = error instanceof Error ? error.message : String(error);
            this.operationLog?.write('error', 'merge', `Merge processing error for task ${taskId}: ${errorMessage}`, { taskId });
            await this.updateMergeStatus(taskId, 'failed', {
                failureReason: errorMessage,
            });
            return {
                taskId,
                status: 'failed',
                merged: false,
                error: errorMessage,
                processedAt,
            };
        }
    }
    async processAllPending(options = {}) {
        const startTime = Date.now();
        const tasks = await this.getTasksAwaitingMerge();
        const results = [];
        let mergedCount = 0;
        let testFailedCount = 0;
        let conflictCount = 0;
        let errorCount = 0;
        for (const { taskId } of tasks) {
            const result = await this.processTask(taskId, options);
            results.push(result);
            switch (result.status) {
                case 'merged':
                    mergedCount++;
                    break;
                case 'test_failed':
                    testFailedCount++;
                    break;
                case 'conflict':
                    conflictCount++;
                    break;
                case 'failed':
                    errorCount++;
                    break;
                case 'skipped':
                    // Another steward already claimed this task — not an error
                    break;
            }
        }
        return {
            totalProcessed: tasks.length,
            mergedCount,
            testFailedCount,
            conflictCount,
            errorCount,
            results,
            durationMs: Date.now() - startTime,
        };
    }
    // ----------------------------------------
    // Individual Operations
    // ----------------------------------------
    async runTests(taskId) {
        const startTime = Date.now();
        // Get task and worktree info
        const task = await this.api.get(taskId);
        if (!task) {
            return {
                passed: false,
                output: `Task not found: ${taskId}`,
                exitCode: 1,
                durationMs: 0,
                testResult: {
                    passed: false,
                    completedAt: createTimestamp(),
                    errorMessage: `Task not found: ${taskId}`,
                },
            };
        }
        const orchestratorMeta = getOrchestratorTaskMeta(task.metadata);
        // Determine working directory
        let cwd = this.config.workspaceRoot;
        if (orchestratorMeta?.worktree && this.worktreeManager) {
            const worktree = await this.worktreeManager.getWorktree(orchestratorMeta.worktree);
            if (worktree) {
                cwd = worktree.path;
            }
            else {
                logger.warn(`Worktree not found for task ${taskId}, falling back to main repo`);
            }
        }
        else {
            logger.warn(`No worktree configured for task ${taskId}, running tests in main repo`);
        }
        try {
            const { stdout, stderr } = await execAsync(this.config.testCommand, {
                cwd,
                timeout: this.config.testTimeoutMs,
                encoding: 'utf8',
            });
            const durationMs = Date.now() - startTime;
            const output = stdout + (stderr ? `\n${stderr}` : '');
            return {
                passed: true,
                output,
                exitCode: 0,
                durationMs,
                testResult: {
                    passed: true,
                    completedAt: createTimestamp(),
                    durationMs,
                },
            };
        }
        catch (error) {
            const durationMs = Date.now() - startTime;
            const execError = error;
            const output = (execError.stdout ?? '') + (execError.stderr ?? '') + (execError.message ?? '');
            const exitCode = execError.code ?? 1;
            return {
                passed: false,
                output,
                exitCode,
                durationMs,
                testResult: {
                    passed: false,
                    completedAt: createTimestamp(),
                    durationMs,
                    errorMessage: output.substring(0, 500), // Truncate for storage
                },
            };
        }
    }
    async attemptMerge(taskId, commitMessage) {
        // Get task info
        const task = await this.api.get(taskId);
        if (!task) {
            return {
                success: false,
                hasConflict: false,
                error: `Task not found: ${taskId}`,
            };
        }
        const orchestratorMeta = getOrchestratorTaskMeta(task.metadata);
        if (!orchestratorMeta?.branch) {
            return {
                success: false,
                hasConflict: false,
                error: 'Task has no branch associated',
            };
        }
        const targetBranch = await this.getTargetBranch();
        const sourceBranch = orchestratorMeta.branch;
        const defaultMessage = this.config.mergeStrategy === 'squash'
            ? `${task.title} (${taskId})`
            : `Merge branch '${sourceBranch}' (Task: ${taskId})`;
        return mergeBranch({
            workspaceRoot: this.config.workspaceRoot,
            sourceBranch,
            targetBranch,
            mergeStrategy: this.config.mergeStrategy,
            autoPush: this.config.autoPushAfterMerge,
            commitMessage: commitMessage ?? defaultMessage,
            preflight: true,
            syncLocal: false,
        });
    }
    async createFixTask(originalTaskId, options) {
        // Get original task info
        const originalTask = await this.api.get(originalTaskId);
        if (!originalTask) {
            throw new Error(`Original task not found: ${originalTaskId}`);
        }
        const orchestratorMeta = getOrchestratorTaskMeta(originalTask.metadata);
        // Check if a fix task already exists for this original task and fix type
        // This prevents creating duplicate fix tasks when tests fail repeatedly
        const existingFixTasks = await this.findExistingFixTasks(originalTaskId, options.type);
        if (existingFixTasks.length > 0) {
            // Return the existing fix task ID instead of creating a duplicate
            logger.info(`Fix task already exists for ${originalTaskId} (type: ${options.type}): ${existingFixTasks[0].id}`);
            return existingFixTasks[0].id;
        }
        // Determine title based on fix type
        let title;
        switch (options.type) {
            case 'test_failure':
                title = `Fix failing tests: ${originalTask.title}`;
                break;
            case 'merge_conflict':
                title = `Resolve merge conflict: ${originalTask.title}`;
                break;
            default:
                title = `Fix: ${originalTask.title}`;
        }
        // Build description
        const lines = [
            `This task was created by the Merge Steward to fix issues with task ${originalTaskId}.`,
            '',
            `## Issue Type`,
            options.type === 'test_failure' ? 'Test failures' :
                options.type === 'merge_conflict' ? 'Merge conflict' : 'General issue',
            '',
            `## Details`,
            options.errorDetails,
        ];
        if (options.affectedFiles && options.affectedFiles.length > 0) {
            lines.push('', '## Affected Files', ...options.affectedFiles.map((f) => `- ${f}`));
        }
        lines.push('', '## Instructions', '1. Fix the issue(s) described above', '2. Run tests to verify: `npm test`', `3. Close the task: \`sf task close <task-id>\``, '', 'The Merge Steward will automatically attempt to merge again.');
        // Create the fix task
        const createdBy = this.config.stewardEntityId ?? originalTask.createdBy;
        const fixTaskData = await createTask({
            title,
            status: TaskStatus.OPEN,
            tags: ['fix', options.type, 'auto-created'],
            priority: originalTask.priority,
            complexity: originalTask.complexity,
            assignee: orchestratorMeta?.assignedAgent,
            createdBy,
            metadata: {
                description: lines.join('\n'),
                orchestrator: {
                    branch: orchestratorMeta?.branch,
                    worktree: orchestratorMeta?.worktree,
                    assignedAgent: orchestratorMeta?.assignedAgent,
                    mergeStatus: 'pending',
                },
                originalTaskId,
                fixType: options.type,
            },
        });
        // Save the task to the database
        const fixTask = await this.api.create(fixTaskData);
        // If there's an assigned agent, notify them
        if (orchestratorMeta?.assignedAgent) {
            const agentChannel = await this.agentRegistry.getAgentChannel(orchestratorMeta.assignedAgent);
            if (agentChannel) {
                // Create a notification document
                const notificationContent = [
                    `# Fix Task Created: ${fixTask.id}`,
                    '',
                    `A fix task has been created for: ${originalTask.title}`,
                    '',
                    `**Issue:** ${options.type === 'test_failure' ? 'Tests are failing' : 'Merge conflict detected'}`,
                    '',
                    'Please review and fix the issue, then close the task.',
                ].join('\n');
                await this.dispatchService.notifyAgent(orchestratorMeta.assignedAgent, 'task-assignment', notificationContent, {
                    fixTaskId: fixTask.id,
                    originalTaskId,
                    fixType: options.type,
                });
            }
        }
        return fixTask.id;
    }
    async cleanupAfterMerge(taskId, deleteBranch = true) {
        if (!this.worktreeManager) {
            return true; // No worktree manager, nothing to clean up
        }
        const task = await this.api.get(taskId);
        if (!task) {
            return false;
        }
        const orchestratorMeta = getOrchestratorTaskMeta(task.metadata);
        if (!orchestratorMeta?.worktree) {
            return true; // No worktree to clean up
        }
        try {
            // Only attempt remote branch deletion when a remote actually exists
            const remoteExists = await hasRemote(this.config.workspaceRoot);
            await this.worktreeManager.removeWorktree(orchestratorMeta.worktree, {
                deleteBranch,
                deleteRemoteBranch: deleteBranch && remoteExists,
                force: false,
            });
            return true;
        }
        catch {
            return false;
        }
    }
    // ----------------------------------------
    // Status Updates
    // ----------------------------------------
    async updateMergeStatus(taskId, status, details, expectedCurrentStatus) {
        const task = await this.api.get(taskId);
        if (!task) {
            throw new Error(`Task not found: ${taskId}`);
        }
        // Optimistic locking: if expectedCurrentStatus is provided, verify the
        // task's current mergeStatus matches before proceeding with the update.
        if (expectedCurrentStatus !== undefined) {
            const currentMeta = getOrchestratorTaskMeta(task.metadata);
            const currentStatus = currentMeta?.mergeStatus;
            if (currentStatus !== expectedCurrentStatus) {
                throw new MergeStatusConflictError(taskId, expectedCurrentStatus, currentStatus);
            }
        }
        const updates = {
            mergeStatus: status,
        };
        if (status === 'merged') {
            updates.mergedAt = createTimestamp();
        }
        if (details?.failureReason) {
            updates.mergeFailureReason = details.failureReason;
        }
        if (details?.testResult) {
            const existingMeta = getOrchestratorTaskMeta(task.metadata);
            updates.lastTestResult = details.testResult;
            updates.testRunCount = (existingMeta?.testRunCount ?? 0) + 1;
        }
        const newMetadata = updateOrchestratorTaskMeta(task.metadata, updates);
        // When merge succeeds or is not applicable, transition task from REVIEW to CLOSED
        // This unblocks dependent tasks via the blocked cache
        const taskUpdates = { metadata: newMetadata };
        if (status === 'merged' || status === 'not_applicable') {
            taskUpdates.status = TaskStatus.CLOSED;
            taskUpdates.closedAt = createTimestamp();
            taskUpdates.assignee = undefined;
        }
        return this.api.update(taskId, taskUpdates);
    }
    // ----------------------------------------
    // Private Helpers
    // ----------------------------------------
    /**
     * Finds existing fix tasks for a given original task and fix type.
     * This prevents creating duplicate fix tasks when tests fail repeatedly.
     */
    async findExistingFixTasks(originalTaskId, fixType) {
        // Query for all tasks with the 'fix' tag
        const allTasks = await this.api.list({
            type: 'task',
            tags: ['fix'],
        });
        // Filter for tasks that:
        // 1. Have metadata.originalTaskId matching our original task
        // 2. Have metadata.fixType matching the requested fix type
        // 3. Are not closed (still relevant)
        const activeStatuses = [TaskStatus.OPEN, TaskStatus.IN_PROGRESS, TaskStatus.REVIEW];
        return allTasks.filter((task) => {
            const meta = task.metadata;
            return (meta?.originalTaskId === originalTaskId &&
                meta?.fixType === fixType &&
                activeStatuses.includes(task.status));
        });
    }
    /**
     * Checks whether a branch has any commits ahead of the target branch.
     * Returns true if there are commits to merge, false if the branch is
     * already fully merged (zero commits ahead).
     */
    async branchHasCommitsAhead(sourceBranch) {
        const targetBranch = await this.getTargetBranch();
        const remoteExists = await hasRemote(this.config.workspaceRoot);
        try {
            // When remote exists, fetch first and compare remote refs
            if (remoteExists) {
                await execAsync('git fetch origin', {
                    cwd: this.config.workspaceRoot,
                    encoding: 'utf8',
                });
                const targetRef = `origin/${targetBranch}`;
                const sourceRef = `origin/${sourceBranch}`;
                const { stdout } = await execAsync(`git rev-list --count ${targetRef}..${sourceRef}`, { cwd: this.config.workspaceRoot, encoding: 'utf8' });
                return parseInt(stdout.trim(), 10) > 0;
            }
            // Local-only: compare local refs
            const { stdout } = await execAsync(`git rev-list --count ${targetBranch}..${sourceBranch}`, { cwd: this.config.workspaceRoot, encoding: 'utf8' });
            return parseInt(stdout.trim(), 10) > 0;
        }
        catch {
            // If we can't determine, assume there are commits to merge
            // and let the normal merge flow handle errors
            return true;
        }
    }
    async getTargetBranch() {
        if (this.targetBranch) {
            return this.targetBranch;
        }
        // Delegate to the canonical detectTargetBranch(), passing config value
        this.targetBranch = await detectTargetBranch(this.config.workspaceRoot, this.config.targetBranch);
        return this.targetBranch;
    }
}
// ============================================================================
// Factory Function
// ============================================================================
/**
 * Creates a MergeStewardService instance
 */
export function createMergeStewardService(api, taskAssignment, dispatchService, agentRegistry, config, worktreeManager, operationLog) {
    return new MergeStewardServiceImpl(api, taskAssignment, dispatchService, agentRegistry, config, worktreeManager, operationLog);
}
//# sourceMappingURL=merge-steward-service.js.map