/**
 * Release manifest operations backed by the release_manifests SQLite table.
 *
 * Migrated from .cleo/releases.json to SQLite per T5580.
 * All reads/writes now go through Drizzle ORM via tasks.db.
 *
 * @task T5580
 * @task T4788
 */
import { execFileSync } from 'node:child_process';
import { existsSync, renameSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { and, count, desc, eq } from 'drizzle-orm';
import { createPage } from '../pagination.js';
import { getCleoDirAbsolute, getProjectRoot } from '../paths.js';
import * as schema from '../store/tasks-schema.js';
import { parseChangelogBlocks, writeChangelogSection } from './changelog-writer.js';
import { resolveChannelFromBranch } from './channel.js';
import { detectBranchProtection } from './github-pr.js';
import { getChannelConfig, getGitFlowConfig, getPushMode, loadReleaseConfig, } from './release-config.js';
import { getVersionBumpConfig } from './version-bump.js';
async function getDb(cwd) {
    const { getDb: _getDb } = await import('../store/sqlite.js');
    return _getDb(cwd);
}
function normalizeLimit(limit) {
    return typeof limit === 'number' && limit > 0 ? limit : undefined;
}
function normalizeOffset(offset) {
    return typeof offset === 'number' && offset > 0 ? offset : undefined;
}
function effectivePageLimit(limit, offset) {
    return limit ?? (offset !== undefined ? 50 : undefined);
}
// ── Internal helpers ─────────────────────────────────────────────────
function isValidVersion(version) {
    return /^v?\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/.test(version);
}
function validateCalVerWindow(version, now = new Date()) {
    const normalized = version.startsWith('v') ? version.slice(1) : version;
    const base = normalized.split('-')[0] ?? normalized;
    const parts = base.split('.');
    if (parts.length !== 3) {
        return { valid: false, message: `Invalid CalVer format: ${version}` };
    }
    const tagYear = Number.parseInt(parts[0] ?? '', 10);
    const tagMonth = Number.parseInt(parts[1] ?? '', 10);
    if (!Number.isInteger(tagYear) || !Number.isInteger(tagMonth)) {
        return { valid: false, message: `Invalid CalVer date components: ${version}` };
    }
    const currentYear = now.getUTCFullYear();
    const currentMonth = now.getUTCMonth() + 1;
    const nextMonth = currentMonth === 12 ? 1 : currentMonth + 1;
    const nextYear = currentMonth === 12 ? currentYear + 1 : currentYear;
    const isPreRelease = normalized.includes('-');
    if (isPreRelease) {
        const valid = (tagYear === currentYear || tagYear === nextYear) &&
            (tagMonth === currentMonth || tagMonth === nextMonth);
        return {
            valid,
            message: valid
                ? `CalVer OK (pre-release): ${version}`
                : `Pre-release ${version} outside allowed CalVer range ${currentYear}.${currentMonth} or ${nextYear}.${nextMonth}`,
        };
    }
    const valid = tagYear === currentYear && tagMonth === currentMonth;
    return {
        valid,
        message: valid
            ? `CalVer OK (stable): ${version}`
            : `${version} does not match current CalVer ${currentYear}.${currentMonth}`,
    };
}
function normalizeVersion(version) {
    return version.startsWith('v') ? version : `v${version}`;
}
function rowToManifest(row) {
    return {
        version: row.version,
        status: row.status,
        createdAt: row.createdAt,
        preparedAt: row.preparedAt ?? undefined,
        committedAt: row.committedAt ?? undefined,
        taggedAt: row.taggedAt ?? undefined,
        pushedAt: row.pushedAt ?? undefined,
        tasks: JSON.parse(row.tasksJson),
        notes: row.notes ?? undefined,
        changelog: row.changelog ?? undefined,
        previousVersion: row.previousVersion ?? undefined,
        commitSha: row.commitSha ?? undefined,
        gitTag: row.gitTag ?? undefined,
    };
}
async function findLatestPushedVersion(cwd) {
    const db = await getDb(cwd);
    const rows = await db
        .select({ version: schema.releaseManifests.version })
        .from(schema.releaseManifests)
        .where(eq(schema.releaseManifests.status, 'pushed'))
        .orderBy(desc(schema.releaseManifests.pushedAt))
        .limit(1)
        .all();
    return rows[0]?.version;
}
// ── Public API ───────────────────────────────────────────────────────
/**
 * Prepare a release (create a release manifest entry).
 * @task T4788
 */
export async function prepareRelease(version, tasks, notes, loadTasksFn, cwd) {
    if (!version) {
        throw new Error('version is required');
    }
    if (!isValidVersion(version)) {
        throw new Error(`Invalid version format: ${version} (expected X.Y.Z or YYYY.M.patch)`);
    }
    const normalizedVersion = normalizeVersion(version);
    const db = await getDb(cwd);
    const existing = await db
        .select()
        .from(schema.releaseManifests)
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .limit(1)
        .all();
    if (existing.length > 0) {
        throw new Error(`Release ${normalizedVersion} already exists (status: ${existing[0].status})`);
    }
    let releaseTasks = tasks ?? [];
    if (releaseTasks.length === 0) {
        const allTasks = await loadTasksFn();
        releaseTasks = allTasks.filter((t) => t.status === 'done' && t.completedAt).map((t) => t.id);
    }
    // Filter out epic IDs
    const allTasks = await loadTasksFn();
    const epicIds = new Set(allTasks.filter((t) => allTasks.some((c) => c.parentId === t.id)).map((t) => t.id));
    releaseTasks = releaseTasks.filter((id) => !epicIds.has(id));
    const previousVersion = await findLatestPushedVersion(cwd);
    const now = new Date().toISOString();
    const id = `rel-${normalizedVersion.replace(/[^a-z0-9]/gi, '-')}`;
    await db
        .insert(schema.releaseManifests)
        .values({
        id,
        version: normalizedVersion,
        status: 'prepared',
        tasksJson: JSON.stringify(releaseTasks),
        notes: notes ?? null,
        previousVersion: previousVersion ?? null,
        createdAt: now,
        preparedAt: now,
    })
        .run();
    return {
        version: normalizedVersion,
        status: 'prepared',
        tasks: releaseTasks,
        taskCount: releaseTasks.length,
    };
}
/**
 * Generate changelog for a release.
 * @task T4788
 */
export async function generateReleaseChangelog(version, loadTasksFn, cwd) {
    if (!version) {
        throw new Error('version is required');
    }
    const normalizedVersion = normalizeVersion(version);
    const db = await getDb(cwd);
    const rows = await db
        .select()
        .from(schema.releaseManifests)
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .limit(1)
        .all();
    if (rows.length === 0) {
        throw new Error(`Release ${normalizedVersion} not found`);
    }
    const row = rows[0];
    const releaseTasks = JSON.parse(row.tasksJson);
    const allTasks = await loadTasksFn();
    const taskMap = new Map(allTasks.map((t) => [t.id, t]));
    const features = [];
    const fixes = [];
    const chores = [];
    const docs = [];
    const tests = [];
    const changes = [];
    /**
     * Strip conventional commit prefixes from task titles.
     * e.g. "feat: add auth" → "Add auth", "fix(ui): button" → "Button"
     */
    function stripConventionalPrefix(title) {
        return title.replace(/^(feat|fix|docs?|test|chore|refactor|style|ci|build|perf)(\([^)]+\))?:\s*/i, '');
    }
    /**
     * Capitalize the first character of a string.
     */
    function capitalize(s) {
        return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
    }
    /**
     * Build a changelog entry line for a task.
     * Uses description to enrich the entry when it's meaningfully different from the title.
     */
    function buildEntry(task) {
        const cleanTitle = capitalize(stripConventionalPrefix(task.title));
        // Strip newlines and collapse whitespace in description
        const safeDesc = task.description
            ?.replace(/\r?\n/g, ' ')
            .replace(/\s{2,}/g, ' ')
            .trim();
        const desc = safeDesc;
        // Include description only when it's non-trivial and adds information beyond the title.
        // Skip if: description is empty, identical to title, or a minor rephrasing (≤10% longer, no new words).
        const shouldIncludeDesc = (() => {
            if (!desc || desc.length === 0)
                return false;
            const titleNorm = cleanTitle
                .toLowerCase()
                .replace(/[^a-z0-9\s]/g, '')
                .trim();
            const descNorm = desc
                .toLowerCase()
                .replace(/[^a-z0-9\s]/g, '')
                .trim();
            if (titleNorm === descNorm)
                return false;
            if (descNorm.startsWith(titleNorm) && descNorm.length < titleNorm.length * 1.3)
                return false;
            // Require description to be at least 20 chars and contain different content
            return desc.length >= 20;
        })();
        if (shouldIncludeDesc) {
            // Truncate long descriptions to keep changelog readable
            const descDisplay = desc.length > 150 ? desc.slice(0, 147) + '...' : desc;
            return `- **${cleanTitle}**: ${descDisplay} (${task.id})`;
        }
        return `- ${cleanTitle} (${task.id})`;
    }
    /**
     * Categorize a task into a changelog section.
     * Priority order:
     *   1. task.type field ('epic' → skip, others are hints for 'task'/'subtask')
     *   2. task.labels array
     *   3. Title keyword scan (with conventional prefix stripped)
     */
    function categorizeTask(task) {
        // Fix A: Skip epics entirely — they are parent containers, not deliverables
        if (task.type === 'epic')
            return 'changes'; // Will be filtered out by the caller
        // Priority 1: task.type field is the most authoritative signal
        const taskType = (task.type ?? '').toLowerCase();
        if (taskType === 'test')
            return 'tests';
        if (taskType === 'fix' || taskType === 'bugfix')
            return 'fixes';
        if (taskType === 'feat' || taskType === 'feature')
            return 'features';
        if (taskType === 'docs' || taskType === 'doc')
            return 'docs';
        if (taskType === 'chore' || taskType === 'refactor')
            return 'chores';
        // Priority 2: conventional commit prefix in raw title
        if (/^feat(\([^)]+\))?:/.test(task.title.toLowerCase()))
            return 'features';
        if (/^fix(\([^)]+\))?:/.test(task.title.toLowerCase()))
            return 'fixes';
        if (/^docs?(\([^)]+\))?:/.test(task.title.toLowerCase()))
            return 'docs';
        if (/^test(\([^)]+\))?:/.test(task.title.toLowerCase()))
            return 'tests';
        if (/^(chore|refactor|style|ci|build|perf)(\([^)]+\))?:/.test(task.title.toLowerCase()))
            return 'chores';
        // Priority 3: labels for strong category signals
        const labels = task.labels ?? [];
        if (labels.some((l) => ['test', 'testing'].includes(l.toLowerCase())))
            return 'tests';
        if (labels.some((l) => ['fix', 'bug', 'bugfix', 'regression'].includes(l.toLowerCase())))
            return 'fixes';
        if (labels.some((l) => ['feat', 'feature', 'enhancement', 'add'].includes(l.toLowerCase())))
            return 'features';
        if (labels.some((l) => ['docs', 'documentation'].includes(l.toLowerCase())))
            return 'docs';
        if (labels.some((l) => ['chore', 'refactor', 'cleanup', 'maintenance'].includes(l.toLowerCase())))
            return 'chores';
        // Priority 4: keyword scan on the cleaned title
        const titleLower = stripConventionalPrefix(task.title).toLowerCase();
        const rawTitleLower = task.title.toLowerCase();
        if (titleLower.startsWith('test') ||
            (titleLower.includes('test') && titleLower.includes('add')))
            return 'tests';
        if (titleLower.includes('bug') ||
            titleLower.startsWith('fix') ||
            titleLower.includes('regression') ||
            titleLower.includes('broken'))
            return 'fixes';
        if (titleLower.startsWith('add ') ||
            titleLower.includes('implement') ||
            titleLower.startsWith('create ') ||
            titleLower.startsWith('introduce '))
            return 'features';
        if (titleLower.startsWith('doc') ||
            titleLower.includes('documentation') ||
            titleLower.includes('readme') ||
            titleLower.includes('changelog'))
            return 'docs';
        if (titleLower.startsWith('chore') ||
            titleLower.includes('refactor') ||
            titleLower.includes('cleanup') ||
            titleLower.includes('migrate') ||
            titleLower.includes('upgrade') ||
            titleLower.includes('remove ') ||
            titleLower.startsWith('audit'))
            return 'chores';
        // Raw title scan for backward compat
        if (rawTitleLower.startsWith('feat'))
            return 'features';
        return 'changes';
    }
    for (const taskId of releaseTasks) {
        const task = taskMap.get(taskId);
        if (!task)
            continue;
        // Fix A: Filter out epics — they are containers, not changelog entries
        if (task.type === 'epic')
            continue;
        // Also filter by label in case type field is not populated
        if (task.labels?.some((l) => l.toLowerCase() === 'epic'))
            continue;
        // Heuristic: titles starting with "EPIC:" are epics even without type field
        if (/^epic:/i.test(task.title.trim()))
            continue;
        // Filter out research/internal/spike/audit tasks — not user-facing deliverables
        const labelsLower = (task.labels ?? []).map((l) => l.toLowerCase());
        if (labelsLower.some((l) => ['research', 'internal', 'spike', 'audit'].includes(l)))
            continue;
        if (['spike', 'research'].includes((task.type ?? '').toLowerCase()))
            continue;
        if (/^(research|investigate|audit|spike)\s/i.test(task.title.trim()))
            continue;
        const category = categorizeTask(task);
        const entry = buildEntry(task);
        if (category === 'features')
            features.push(entry);
        else if (category === 'fixes')
            fixes.push(entry);
        else if (category === 'docs')
            docs.push(entry);
        else if (category === 'tests')
            tests.push(entry);
        else if (category === 'chores')
            chores.push(entry);
        else
            changes.push(entry);
    }
    const sections = [];
    const date = new Date().toISOString().split('T')[0];
    sections.push(`## ${normalizedVersion} (${date})`);
    sections.push('');
    if (row.notes) {
        sections.push(row.notes);
        sections.push('');
    }
    if (features.length > 0) {
        sections.push('### Features');
        sections.push(...features);
        sections.push('');
    }
    if (fixes.length > 0) {
        sections.push('### Bug Fixes');
        sections.push(...fixes);
        sections.push('');
    }
    if (docs.length > 0) {
        sections.push('### Documentation');
        sections.push(...docs);
        sections.push('');
    }
    if (tests.length > 0) {
        sections.push('### Tests');
        sections.push(...tests);
        sections.push('');
    }
    if (chores.length > 0) {
        sections.push('### Chores');
        sections.push(...chores);
        sections.push('');
    }
    if (changes.length > 0) {
        sections.push('### Changes');
        sections.push(...changes);
        sections.push('');
    }
    const changelog = sections.join('\n');
    await db
        .update(schema.releaseManifests)
        .set({ changelog })
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .run();
    // Write or update CHANGELOG.md with section-aware merge
    const changelogPath = join(cwd ?? process.cwd(), 'CHANGELOG.md');
    let existingChangelogContent = '';
    try {
        existingChangelogContent = await readFile(changelogPath, 'utf8');
    }
    catch {
        // File doesn't exist yet — start fresh
    }
    const { customBlocks } = parseChangelogBlocks(existingChangelogContent);
    // Build the changelog body (content after the ## header line)
    const changelogBody = sections.slice(2).join('\n'); // skip header + blank line
    await writeChangelogSection(normalizedVersion.replace(/^v/, ''), changelogBody, customBlocks, changelogPath);
    return {
        version: normalizedVersion,
        changelog,
        taskCount: releaseTasks.length,
        sections: {
            features: features.length,
            fixes: fixes.length,
            docs: docs.length,
            tests: tests.length,
            chores: chores.length,
            changes: changes.length,
        },
    };
}
/**
 * List all releases.
 * @task T4788
 */
export async function listManifestReleases(optionsOrCwd, cwd) {
    const options = typeof optionsOrCwd === 'string' || optionsOrCwd === undefined ? {} : optionsOrCwd;
    const effectiveCwd = typeof optionsOrCwd === 'string' ? optionsOrCwd : cwd;
    const limit = normalizeLimit(options.limit);
    const offset = normalizeOffset(options.offset);
    const pageLimit = effectivePageLimit(limit, offset);
    const db = await getDb(effectiveCwd);
    const totalRow = await db.select({ count: count() }).from(schema.releaseManifests).get();
    const total = totalRow?.count ?? 0;
    const conditions = options.status ? [eq(schema.releaseManifests.status, options.status)] : [];
    const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
    const filteredRow = await db
        .select({ count: count() })
        .from(schema.releaseManifests)
        .where(whereClause)
        .get();
    const filtered = filteredRow?.count ?? 0;
    let query = db
        .select()
        .from(schema.releaseManifests)
        .where(whereClause)
        .orderBy(desc(schema.releaseManifests.createdAt));
    if (pageLimit !== undefined) {
        query = query.limit(pageLimit);
    }
    if (offset !== undefined) {
        query = query.offset(offset);
    }
    const rows = await query.all();
    const latest = await findLatestPushedVersion(effectiveCwd);
    return {
        releases: rows.map((r) => ({
            version: r.version,
            status: r.status,
            createdAt: r.createdAt,
            taskCount: JSON.parse(r.tasksJson).length,
        })),
        total,
        filtered,
        latest,
        page: createPage({ total: filtered, limit: pageLimit, offset }),
    };
}
/**
 * Show release details.
 * @task T4788
 */
export async function showManifestRelease(version, cwd) {
    if (!version) {
        throw new Error('version is required');
    }
    const normalizedVersion = normalizeVersion(version);
    const db = await getDb(cwd);
    const rows = await db
        .select()
        .from(schema.releaseManifests)
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .limit(1)
        .all();
    if (rows.length === 0) {
        throw new Error(`Release ${normalizedVersion} not found`);
    }
    return rowToManifest(rows[0]);
}
/**
 * Mark release as committed (metadata only).
 * @task T4788
 */
export async function commitRelease(version, cwd) {
    if (!version) {
        throw new Error('version is required');
    }
    const normalizedVersion = normalizeVersion(version);
    const db = await getDb(cwd);
    const rows = await db
        .select()
        .from(schema.releaseManifests)
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .limit(1)
        .all();
    if (rows.length === 0) {
        throw new Error(`Release ${normalizedVersion} not found`);
    }
    if (rows[0].status !== 'prepared') {
        throw new Error(`Release ${normalizedVersion} is in state '${rows[0].status}', expected 'prepared'`);
    }
    const committedAt = new Date().toISOString();
    await db
        .update(schema.releaseManifests)
        .set({ status: 'committed', committedAt })
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .run();
    return { version: normalizedVersion, status: 'committed', committedAt };
}
/**
 * Mark release as tagged (metadata only).
 * @task T4788
 */
export async function tagRelease(version, cwd) {
    if (!version) {
        throw new Error('version is required');
    }
    const normalizedVersion = normalizeVersion(version);
    const db = await getDb(cwd);
    const rows = await db
        .select()
        .from(schema.releaseManifests)
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .limit(1)
        .all();
    if (rows.length === 0) {
        throw new Error(`Release ${normalizedVersion} not found`);
    }
    const taggedAt = new Date().toISOString();
    await db
        .update(schema.releaseManifests)
        .set({ status: 'tagged', taggedAt })
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .run();
    return { version: normalizedVersion, status: 'tagged', taggedAt };
}
/**
 * Run release validation gates.
 * @task T4788
 * @task T5586
 */
export async function runReleaseGates(version, loadTasksFn, cwd, opts) {
    if (!version) {
        throw new Error('version is required');
    }
    const normalizedVersion = normalizeVersion(version);
    const db = await getDb(cwd);
    const rows = await db
        .select()
        .from(schema.releaseManifests)
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .limit(1)
        .all();
    if (rows.length === 0) {
        throw new Error(`Release ${normalizedVersion} not found`);
    }
    const row = rows[0];
    const releaseTasks = JSON.parse(row.tasksJson);
    const gates = [];
    gates.push({
        name: 'version_valid',
        status: isValidVersion(normalizedVersion) ? 'passed' : 'failed',
        message: isValidVersion(normalizedVersion)
            ? 'Version format is valid'
            : 'Invalid version format',
    });
    const releaseConfig = loadReleaseConfig(cwd);
    if (releaseConfig.versioningScheme === 'calver') {
        const calver = validateCalVerWindow(normalizedVersion);
        gates.push({
            name: 'calver_window',
            status: calver.valid ? 'passed' : 'failed',
            message: calver.message,
        });
    }
    gates.push({
        name: 'has_tasks',
        status: releaseTasks.length > 0 ? 'passed' : 'failed',
        message: releaseTasks.length > 0 ? `${releaseTasks.length} tasks included` : 'No tasks in release',
    });
    gates.push({
        name: 'has_changelog',
        status: row.changelog ? 'passed' : 'failed',
        message: row.changelog
            ? 'Changelog generated'
            : 'No changelog generated. Run release.changelog first.',
    });
    const allTasks = await loadTasksFn();
    const incompleteTasks = releaseTasks.filter((id) => {
        const task = allTasks.find((t) => t.id === id);
        return task && task.status !== 'done';
    });
    gates.push({
        name: 'tasks_complete',
        status: incompleteTasks.length === 0 ? 'passed' : 'failed',
        message: incompleteTasks.length === 0
            ? 'All tasks completed'
            : `${incompleteTasks.length} tasks not completed: ${incompleteTasks.join(', ')}`,
    });
    // G2: Build artifact — dist/ must exist (Node projects only)
    // Monorepo-aware: checks packages/cleo/dist/ first, then root dist/
    const projectRoot = cwd ?? getProjectRoot();
    const monorepoDist = join(projectRoot, 'packages', 'cleo', 'dist', 'cli', 'index.js');
    const rootDist = join(projectRoot, 'dist', 'cli', 'index.js');
    const distExists = existsSync(monorepoDist) || existsSync(rootDist);
    const isNodeProject = existsSync(join(projectRoot, 'package.json'));
    if (isNodeProject) {
        gates.push({
            name: 'build_artifact',
            status: distExists ? 'passed' : 'failed',
            message: distExists ? 'Build artifacts present' : 'dist/ not built — run: pnpm run build',
        });
    }
    // GD1: Clean working tree (CHANGELOG.md and version bump targets are allowed to be dirty)
    // Skipped in dry-run mode — dry-run makes no commits so tree cleanliness is irrelevant.
    // Untracked files (?? lines) are excluded from the dirty check — they do not affect git
    // commit/tag operations and must not block releases.
    if (opts?.dryRun) {
        gates.push({
            name: 'clean_working_tree',
            status: 'passed',
            message: 'Skipped in dry-run mode',
        });
    }
    else {
        // Dynamically build exclusion set from configured version bump targets + CHANGELOG.md
        const bumpTargets = getVersionBumpConfig(cwd);
        const allowedDirty = new Set(['CHANGELOG.md', ...bumpTargets.map((t) => t.file)]);
        let workingTreeClean = true;
        let dirtyFiles = [];
        try {
            const porcelain = execFileSync('git', ['status', '--porcelain'], {
                cwd: projectRoot,
                encoding: 'utf-8',
                stdio: 'pipe',
            });
            dirtyFiles = porcelain
                .split('\n')
                .filter((l) => l.trim())
                // Exclude untracked files (?? prefix) — they don't affect commits or tags
                .filter((l) => !l.startsWith('?? '))
                .map((l) => l.slice(3).trim())
                .filter((f) => !allowedDirty.has(f));
            workingTreeClean = dirtyFiles.length === 0;
        }
        catch {
            /* git not available — skip */
        }
        const excludeList = [...allowedDirty].join(', ');
        gates.push({
            name: 'clean_working_tree',
            status: workingTreeClean ? 'passed' : 'failed',
            message: workingTreeClean
                ? `Working tree clean (excluding ${excludeList})`
                : `Uncommitted changes in: ${dirtyFiles.slice(0, 5).join(', ')}${dirtyFiles.length > 5 ? ` (+${dirtyFiles.length - 5} more)` : ''}`,
        });
    }
    // GD2: Branch target — use GitFlow config if available, else defaults
    const isPreRelease = normalizedVersion.includes('-');
    let currentBranch = '';
    try {
        currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
            cwd: projectRoot,
            encoding: 'utf-8',
            stdio: 'pipe',
        }).trim();
    }
    catch {
        /* git not available — skip */
    }
    const gitFlowCfg = getGitFlowConfig(releaseConfig);
    const channelCfg = getChannelConfig(releaseConfig);
    const expectedBranch = isPreRelease ? gitFlowCfg.branches.develop : gitFlowCfg.branches.main;
    const isFeatureBranch = currentBranch.startsWith(gitFlowCfg.branches.featurePrefix) ||
        currentBranch.startsWith(gitFlowCfg.branches.hotfixPrefix) ||
        currentBranch.startsWith(gitFlowCfg.branches.releasePrefix);
    const branchOk = !currentBranch || // git unavailable → pass
        currentBranch === 'HEAD' || // detached HEAD → pass
        currentBranch === expectedBranch || // exactly right branch → pass
        (isPreRelease && isFeatureBranch); // feature/hotfix/release branch with pre-release → pass
    // Resolve channel from current branch
    const detectedChannel = currentBranch
        ? resolveChannelFromBranch(currentBranch, channelCfg)
        : isPreRelease
            ? 'beta'
            : 'latest';
    gates.push({
        name: 'branch_target',
        status: branchOk ? 'passed' : 'failed',
        message: branchOk
            ? `On correct branch: ${currentBranch} (channel: ${detectedChannel})`
            : `Expected branch '${expectedBranch}' for ${isPreRelease ? 'pre-release' : 'stable'} release, but on '${currentBranch}'`,
    });
    // GD3: Branch protection — detect if push requires a PR (informational, never fails)
    const pushMode = getPushMode(releaseConfig);
    let requiresPR = false;
    if (pushMode === 'pr') {
        requiresPR = true;
    }
    else if (pushMode === 'auto') {
        try {
            const protectionResult = await detectBranchProtection(expectedBranch, 'origin', projectRoot);
            requiresPR = protectionResult.protected;
        }
        catch {
            // Branch protection detection is best-effort; never block release
            requiresPR = false;
        }
    }
    gates.push({
        name: 'branch_protection',
        status: 'passed',
        message: requiresPR
            ? `Branch '${expectedBranch}' is protected — release.ship will create a PR`
            : `Branch '${expectedBranch}' allows direct push`,
    });
    const allPassed = gates.every((g) => g.status === 'passed');
    const metadata = {
        channel: detectedChannel,
        requiresPR,
        targetBranch: expectedBranch,
        currentBranch,
    };
    return {
        version: normalizedVersion,
        allPassed,
        gates,
        passedCount: gates.filter((g) => g.status === 'passed').length,
        failedCount: gates.filter((g) => g.status === 'failed').length,
        metadata,
    };
}
/**
 * Cancel and remove a release in draft or prepared state.
 * Only releases that have not yet been committed to git can be cancelled.
 * For committed/tagged/pushed releases, use rollbackRelease() instead.
 *
 * @task T5602
 */
export async function cancelRelease(version, projectRoot) {
    if (!version) {
        throw new Error('version is required');
    }
    const normalizedVersion = normalizeVersion(version);
    const db = await getDb(projectRoot);
    const rows = await db
        .select()
        .from(schema.releaseManifests)
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .limit(1)
        .all();
    if (rows.length === 0) {
        return {
            success: false,
            message: `Release ${normalizedVersion} not found`,
            version: normalizedVersion,
        };
    }
    const status = rows[0].status;
    const cancellableStates = ['draft', 'prepared'];
    if (!cancellableStates.includes(status)) {
        return {
            success: false,
            message: `Cannot cancel a release in '${status}' state. Use 'release rollback' instead.`,
            version: normalizedVersion,
        };
    }
    await db
        .delete(schema.releaseManifests)
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .run();
    return {
        success: true,
        message: `Release ${normalizedVersion} cancelled and removed`,
        version: normalizedVersion,
    };
}
/**
 * Rollback a release.
 * @task T4788
 */
export async function rollbackRelease(version, reason, cwd) {
    if (!version) {
        throw new Error('version is required');
    }
    const normalizedVersion = normalizeVersion(version);
    const db = await getDb(cwd);
    const rows = await db
        .select()
        .from(schema.releaseManifests)
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .limit(1)
        .all();
    if (rows.length === 0) {
        throw new Error(`Release ${normalizedVersion} not found`);
    }
    const previousStatus = rows[0].status;
    await db
        .update(schema.releaseManifests)
        .set({ status: 'rolled_back' })
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .run();
    return {
        version: normalizedVersion,
        previousStatus,
        status: 'rolled_back',
        reason: reason ?? 'No reason provided',
    };
}
/**
 * Read push policy from project config.
 * Returns undefined if no push config exists.
 */
async function readPushPolicy(cwd) {
    const configPath = join(getCleoDirAbsolute(cwd), 'config.json');
    let config;
    try {
        const raw = await readFile(configPath, 'utf-8');
        config = JSON.parse(raw);
    }
    catch {
        return undefined;
    }
    if (!config)
        return undefined;
    const release = config.release;
    if (!release)
        return undefined;
    return release.push;
}
/**
 * Push release to remote via git.
 *
 * Respects config.release.push policy:
 * - remote: override default remote (fallback to 'origin')
 * - requireCleanTree: verify git working tree is clean before push
 * - allowedBranches: verify current branch is in the allowed list
 * - enabled: if false and no explicit push flag, caller should skip
 *
 * @task T4788
 * @task T4276
 */
export async function pushRelease(version, remote, cwd, opts) {
    if (!version) {
        throw new Error('version is required');
    }
    const normalizedVersion = normalizeVersion(version);
    const projectRoot = getProjectRoot(cwd);
    const pushPolicy = await readPushPolicy(cwd);
    // Resolve effective push mode: opts.mode > pushPolicy.mode > config > 'direct'
    const configPushMode = getPushMode(loadReleaseConfig(cwd));
    const effectivePushMode = opts?.mode ?? pushPolicy?.mode ?? configPushMode;
    // If branch protection detected and mode allows PR creation, signal PR required
    if (effectivePushMode === 'pr' || effectivePushMode === 'auto') {
        const targetRemoteForCheck = remote ?? pushPolicy?.remote ?? 'origin';
        let branchIsProtected = effectivePushMode === 'pr'; // 'pr' always requires PR
        if (effectivePushMode === 'auto') {
            try {
                const protection = await detectBranchProtection(pushPolicy?.allowedBranches?.[0] ?? 'main', targetRemoteForCheck, projectRoot);
                branchIsProtected = protection.protected;
            }
            catch {
                // Best-effort; default to direct push if detection fails
                branchIsProtected = false;
            }
        }
        if (branchIsProtected) {
            return {
                version: normalizedVersion,
                status: 'requires_pr',
                remote: targetRemoteForCheck,
                pushedAt: new Date().toISOString(),
                requiresPR: true,
            };
        }
    }
    // If push policy says disabled and caller didn't explicitly pass --push, skip
    if (pushPolicy && pushPolicy.enabled === false && !opts?.explicitPush) {
        throw new Error('Push is disabled by config (release.push.enabled=false). Use --push to override.');
    }
    // Determine remote: explicit param > config > 'origin'
    const targetRemote = remote ?? pushPolicy?.remote ?? 'origin';
    // Check requireCleanTree
    // Untracked files (?? lines) are excluded — they do not affect push operations.
    if (pushPolicy?.requireCleanTree) {
        const statusOutput = execFileSync('git', ['status', '--porcelain'], {
            cwd: projectRoot,
            timeout: 10000,
            encoding: 'utf-8',
            stdio: ['pipe', 'pipe', 'pipe'],
        });
        const trackedDirty = statusOutput
            .split('\n')
            .filter((l) => l.trim() && !l.startsWith('?? '))
            .join('\n');
        if (trackedDirty.trim().length > 0) {
            throw new Error('Git working tree is not clean. Commit or stash changes before pushing (config: release.push.requireCleanTree=true).');
        }
    }
    // Check allowedBranches
    if (pushPolicy?.allowedBranches && pushPolicy.allowedBranches.length > 0) {
        const currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
            cwd: projectRoot,
            timeout: 10000,
            encoding: 'utf-8',
            stdio: ['pipe', 'pipe', 'pipe'],
        }).trim();
        if (!pushPolicy.allowedBranches.includes(currentBranch)) {
            throw new Error(`Current branch '${currentBranch}' is not in allowed branches: ${pushPolicy.allowedBranches.join(', ')} (config: release.push.allowedBranches).`);
        }
    }
    execFileSync('git', ['push', targetRemote, '--follow-tags'], {
        cwd: projectRoot,
        timeout: 60000,
        encoding: 'utf-8',
        stdio: ['pipe', 'pipe', 'pipe'],
    });
    return {
        version: normalizedVersion,
        status: 'pushed',
        remote: targetRemote,
        pushedAt: new Date().toISOString(),
    };
}
/**
 * Update release status after push, with optional provenance fields.
 * @task T4788
 * @task T5580
 */
export async function markReleasePushed(version, pushedAt, cwd, provenance) {
    const normalizedVersion = normalizeVersion(version);
    const db = await getDb(cwd);
    await db
        .update(schema.releaseManifests)
        .set({
        status: 'pushed',
        pushedAt,
        ...(provenance?.commitSha != null ? { commitSha: provenance.commitSha } : {}),
        ...(provenance?.gitTag != null ? { gitTag: provenance.gitTag } : {}),
    })
        .where(eq(schema.releaseManifests.version, normalizedVersion))
        .run();
}
/**
 * One-time migration: read .cleo/releases.json and insert each release into
 * the release_manifests table. Renames the file to releases.json.migrated on success.
 *
 * @task T5580
 */
export async function migrateReleasesJsonToSqlite(projectRoot) {
    const releasesPath = join(getCleoDirAbsolute(projectRoot), 'releases.json');
    if (!existsSync(releasesPath)) {
        return { migrated: 0 };
    }
    let raw;
    try {
        const contents = await readFile(releasesPath, 'utf-8');
        raw = JSON.parse(contents);
    }
    catch {
        return { migrated: 0 };
    }
    if (!raw || !Array.isArray(raw.releases)) {
        return { migrated: 0 };
    }
    const db = await getDb(projectRoot);
    let migrated = 0;
    for (const r of raw.releases) {
        // Skip if already exists by version
        const existing = await db
            .select({ id: schema.releaseManifests.id })
            .from(schema.releaseManifests)
            .where(eq(schema.releaseManifests.version, r.version))
            .limit(1)
            .all();
        if (existing.length > 0)
            continue;
        const id = `rel-${r.version.replace(/[^a-z0-9]/gi, '-')}`;
        await db
            .insert(schema.releaseManifests)
            .values({
            id,
            version: r.version,
            status: r.status,
            tasksJson: JSON.stringify(r.tasks ?? []),
            notes: r.notes ?? null,
            changelog: r.changelog ?? null,
            previousVersion: r.previousVersion ?? null,
            createdAt: r.createdAt,
            preparedAt: r.preparedAt ?? null,
            committedAt: r.committedAt ?? null,
            taggedAt: r.taggedAt ?? null,
            pushedAt: r.pushedAt ?? null,
        })
            .run();
        migrated++;
    }
    // Rename legacy file on success
    renameSync(releasesPath, releasesPath + '.migrated');
    return { migrated };
}
//# sourceMappingURL=release-manifest.js.map