// tools/heal-spec-docs.ts — SPEC-493: Batch retroactive quality healing for spec docs
// SPEC-715: heal_spec_docs is now the SINGLE tool that performs migrations.
//           list_specs is read-only; migrations were moved here.
// SPEC-724: Added dryRun, backup flags and Jaccard goal-scenario drift warning.
import { readFile, readdir, rm } from 'node:fs/promises';
import { atomicWriteFile } from '../engine/safety/atomic-write-file.js';
import { join } from 'node:path';
import { hashProjectPath } from '../storage/base-store.js';
import { refreshProjectStatus } from '../engine/autopilot/state-updater.js';
import { extractFilesFromSpecBody } from '../engine/spec-format/technical-md-populator.js';
import { jaccard } from '../engine/spec-format/jaccard.js';
import { replaceSectionInSpec } from '../engine/spec-format/replace-section.js';
const SPANISH_INDICATORS = [
    'de',
    'del',
    'la',
    'el',
    'en',
    'con',
    'para',
    'por',
    'una',
    'los',
    'las',
    'al',
    'se',
    'que',
    'es',
    'son',
    'fue',
    'está',
];
// ---------------------------------------------------------------------------
// Placeholder detection
// ---------------------------------------------------------------------------
function isPlaceholderTechnical(content) {
    if (content.includes('(pending)')) {
        return true;
    }
    if (/--[a-z]/.test(content)) {
        return true;
    }
    if (content.includes('(to be determined)') &&
        !/\.(ts|tsx|js|jsx|py|rb|go|java|cs)/.test(content)) {
        return true;
    }
    return false;
}
function hasInlineTechnicalFilesPlaceholder(content) {
    return /^##\s+Technical\b[\s\S]*?^##\s+Files\b[\s\S]*?(?:\(pending\)|\(to be determined\)|\bTBD\b|--[a-z])/im.test(content);
}
// ---------------------------------------------------------------------------
// File entry generation
// ---------------------------------------------------------------------------
async function writeTechnicalMd(specDir, isDone, projectPath, specBody) {
    // SPEC-586: Try to extract real file paths from spec body before using placeholders
    let extractedBody = null;
    if (projectPath && specBody !== undefined) {
        try {
            const extracted = await extractFilesFromSpecBody(specBody, projectPath);
            if (extracted !== null && (extracted.create.length > 0 || extracted.modify.length > 0)) {
                const lines = ['## Files', ''];
                if (extracted.create.length > 0) {
                    lines.push('### Create', '');
                    for (const f of extracted.create) {
                        lines.push(`- ${f.path} (${isDone ? 'done' : 'pending'})`);
                    }
                    lines.push('');
                }
                if (extracted.modify.length > 0) {
                    lines.push('### Modify', '');
                    for (const f of extracted.modify) {
                        lines.push(`- ${f.path} (${isDone ? 'done' : 'pending'})`);
                    }
                    lines.push('');
                }
                lines.push('## Notes', '> Auto-populated from spec body by heal_spec_docs (SPEC-586).', '');
                extractedBody = lines.join('\n');
            }
        }
        catch {
            // best-effort — fall through to placeholder
        }
    }
    const specMdPath = join(specDir, 'spec.md');
    if (extractedBody === null) {
        const removedInline = await removeTechnicalSectionIfPlaceholder(specMdPath);
        const removedLegacy = await removeLegacyTechnicalPlaceholder(join(specDir, 'technical.md'));
        return removedInline || removedLegacy;
    }
    const technicalBody = extractedBody;
    // SPEC-579: idempotency check — skip write if section already matches.
    try {
        const existing = await readFile(specMdPath, 'utf-8');
        const currentTechnical = extractTechnicalBodyLocal(existing);
        if (currentTechnical.trim() === technicalBody.trim()) {
            return false;
        }
    }
    catch {
        // spec.md missing — continue to write (replaceSectionInSpec will create the section)
    }
    await replaceSectionInSpec(specMdPath, 'Technical', technicalBody);
    return true;
}
async function removeLegacyTechnicalPlaceholder(technicalPath) {
    let legacy;
    try {
        legacy = await readFile(technicalPath, 'utf-8');
    }
    catch {
        return false;
    }
    if (!isPlaceholderTechnical(legacy)) {
        return false;
    }
    await rm(technicalPath, { force: true });
    return true;
}
async function removeTechnicalSectionIfPlaceholder(specMdPath) {
    let existing;
    try {
        existing = await readFile(specMdPath, 'utf-8');
    }
    catch {
        return false;
    }
    const currentTechnical = extractTechnicalBodyLocal(existing);
    if (currentTechnical === '' || !isPlaceholderTechnical(currentTechnical)) {
        return false;
    }
    const updated = removeTopLevelSection(existing, 'Technical');
    if (updated === existing) {
        return false;
    }
    await atomicWriteFile(specMdPath, updated);
    return true;
}
function removeTopLevelSection(content, sectionName) {
    const normalized = content.replace(/\r\n/g, '\n');
    const masked = normalized.replace(/(```|~~~)[\s\S]*?\1/g, (block) => block.replace(/[^\n]/g, ' '));
    const headingRe = new RegExp(`^##\\s+${escapeRegex(sectionName)}\\b.*$`, 'm');
    const match = headingRe.exec(masked);
    if (!match) {
        return content;
    }
    const afterHeading = match.index + match[0].length;
    const nextRe = /^##\s+\S/gm;
    nextRe.lastIndex = afterHeading;
    const next = nextRe.exec(masked);
    const before = normalized.slice(0, match.index).trimEnd();
    const after = next ? normalized.slice(next.index).trimStart() : '';
    if (before.length === 0) {
        return after.length > 0 ? `${after.trimEnd()}\n` : '';
    }
    if (after.length === 0) {
        return `${before}\n`;
    }
    return `${before}\n\n${after.trimEnd()}\n`;
}
function escapeRegex(input) {
    return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Local extraction of ## Technical body to avoid circular imports. */
function extractTechnicalBodyLocal(content) {
    return extractSectionBodyLocal(content, 'Technical') ?? '';
}
function extractSectionBodyLocal(content, sectionName) {
    const normalized = content.replace(/\r\n/g, '\n');
    const masked = normalized.replace(/(```|~~~)[\s\S]*?\1/g, (block) => block.replace(/[^\n]/g, ' '));
    const headingMatch = new RegExp(`^##\\s+${escapeRegex(sectionName)}\\b.*$`, 'm').exec(masked);
    if (!headingMatch) {
        return null;
    }
    const afterHeading = headingMatch.index + headingMatch[0].length;
    const nextRe = /^##\s+\S/gm;
    nextRe.lastIndex = afterHeading;
    const next = nextRe.exec(masked);
    const end = next ? next.index : normalized.length;
    return normalized.slice(afterHeading, end).trim();
}
// ---------------------------------------------------------------------------
// Spanish / non-English title detection
// ---------------------------------------------------------------------------
function isNonEnglishTitle(title) {
    const words = title.toLowerCase().split(/\s+/);
    const hits = words.filter((w) => SPANISH_INDICATORS.includes(w));
    return hits.length >= 2;
}
async function readFrontmatter(specMdPath) {
    try {
        const raw = await readFile(specMdPath, 'utf-8');
        const titleMatch = /^title:\s*(.+)$/m.exec(raw) ?? /^#\s+(.+)$/m.exec(raw);
        const typeMatch = /^type:\s*(.+)$/m.exec(raw);
        const statusMatch = /^status:\s*(.+)$/m.exec(raw);
        return {
            title: titleMatch?.[1]?.trim() ?? 'untitled',
            type: typeMatch?.[1]?.trim() ?? 'feature',
            status: statusMatch?.[1]?.trim() ?? 'draft',
            body: raw,
        };
    }
    catch {
        return { title: 'untitled', type: 'feature', status: 'draft', body: '' };
    }
}
async function collectSpecs(projectPath) {
    const specsDir = join(projectPath, 'planu', 'specs');
    let entries;
    try {
        entries = await readdir(specsDir);
    }
    catch {
        return [];
    }
    const results = [];
    for (const entry of entries) {
        const specDir = join(specsDir, entry);
        const specMdPath = join(specDir, 'spec.md');
        try {
            const { title, type, status, body } = await readFrontmatter(specMdPath);
            // Extract specId from directory name (e.g. SPEC-001-my-feature)
            const idMatch = /^(SPEC-\d+)/i.exec(entry);
            const specId = idMatch?.[1]?.toUpperCase() ?? entry;
            results.push({ specId, title, type, status, dir: specDir, body });
        }
        catch {
            // skip unreadable dirs
        }
    }
    return results;
}
// ---------------------------------------------------------------------------
// Main handler
// ---------------------------------------------------------------------------
/** SPEC-724: Extract the ## Goal section from a spec body. */
function extractGoalSection(body) {
    const match = /^## Goal\s*\n([\s\S]*?)(?=^##|$)/m.exec(body);
    return match?.[1]?.trim() ?? '';
}
/** SPEC-724: Extract the first THEN clause from a spec body's BDD scenarios. */
function extractFirstScenarioThen(body) {
    const match = /\bTHEN\b([\s\S]+?)(?=\n(?:AND|GIVEN|WHEN|THEN|###|##)|$)/m.exec(body);
    return match?.[1]?.trim() ?? '';
}
/** SPEC-724: Compute Jaccard drift warning for Goal vs first scenario THEN clause. */
function computeGoalScenarioDriftWarning(body) {
    const goalSection = extractGoalSection(body);
    const firstThen = extractFirstScenarioThen(body);
    if (!goalSection || !firstThen) {
        return null;
    }
    const score = jaccard(goalSection, firstThen);
    if (score >= 0.85) {
        return null;
    }
    return {
        code: 'GOAL_SCENARIO_DRIFT',
        score,
        severity: score < 0.5 ? 'warn' : 'info',
        hint: `Goal and first scenario THEN clause share only ${Math.round(score * 100)}% of tokens. Consider aligning them.`,
    };
}
export async function handleHealSpecDocs(params) {
    const { projectPath, specId, dryRun = false, backup = true } = params;
    const projectId = hashProjectPath(projectPath);
    let statusJsonRepaired = false;
    let healed = 0;
    let titlesNonEnglish = 0;
    let dryRunDiff;
    let backupPath;
    const warnings = [];
    // A) Repair status.json
    try {
        await refreshProjectStatus(projectPath, projectId);
        statusJsonRepaired = true;
    }
    catch {
        // best-effort
    }
    // B & C) Scan specs for placeholder technical.md and non-English titles
    // SPEC-579: when specId provided (cascade from spec-specific event),
    // scope healing to that single spec instead of mass-rewriting all 400+ files.
    const allSpecs = await collectSpecs(projectPath);
    const specs = specId !== undefined && specId !== ''
        ? allSpecs.filter((s) => s.specId.toUpperCase() === specId.toUpperCase())
        : allSpecs;
    for (const spec of specs) {
        // C) Non-English title detection (never modifies)
        if (isNonEnglishTitle(spec.title)) {
            titlesNonEnglish += 1;
        }
        // SPEC-724: Compute goal-scenario drift warning using Jaccard similarity
        const driftWarning = computeGoalScenarioDriftWarning(spec.body);
        if (driftWarning !== null) {
            warnings.push(driftWarning);
        }
        // B) Placeholder ## Technical section detection (SPEC-1010 PR-C: unified spec.md)
        // Also check standalone technical.md for backwards compat with legacy specs.
        const specMdPath = join(spec.dir, 'spec.md');
        const technicalPath = join(spec.dir, 'technical.md');
        // Determine if healing is needed: check ## Technical in spec.md body first,
        // then fall back to standalone technical.md content.
        const technicalInSpecMd = spec.body.includes('## Technical')
            ? extractTechnicalBodyLocal(spec.body)
            : null;
        let legacyContent = null;
        // Determine whether healing is needed
        let needsHeal;
        const inlinePlaceholder = technicalInSpecMd !== null &&
            (isPlaceholderTechnical(technicalInSpecMd) || hasInlineTechnicalFilesPlaceholder(spec.body));
        if (technicalInSpecMd !== null) {
            // Check if the inline section is a placeholder
            needsHeal = inlinePlaceholder;
        }
        else {
            // No inline section — try standalone technical.md (legacy)
            try {
                const oldContent = await readFile(technicalPath, 'utf-8');
                legacyContent = oldContent;
                if (!isPlaceholderTechnical(oldContent)) {
                    // Non-placeholder legacy file: migration will handle merging; skip heal
                    continue;
                }
                needsHeal = true;
            }
            catch {
                // technical.md missing AND no inline ## Technical — needs creation
                needsHeal = true;
            }
        }
        if (!needsHeal) {
            continue;
        }
        // SPEC-724: dryRun mode — compute diff but skip write
        if (dryRun) {
            dryRunDiff = `--- ${specMdPath} (## Technical)\n+++ ${specMdPath} (## Technical healed)\n[heal would regenerate ## Technical section]`;
            healed += 1;
            continue;
        }
        // SPEC-724: backup before writing (backup the spec.md since that is now the target)
        if (backup) {
            const specContent = await readFile(specMdPath, 'utf-8').catch(() => '');
            if (specContent.length > 0) {
                const bkPath = `${specMdPath}.bak.${Date.now()}`;
                try {
                    await atomicWriteFile(bkPath, specContent);
                    backupPath = bkPath;
                }
                catch {
                    // abort heal if backup fails
                    continue;
                }
            }
            else if (legacyContent !== null) {
                // Backup legacy technical.md content for recovery if needed
                const bkPath = `${technicalPath}.bak.${Date.now()}`;
                try {
                    await atomicWriteFile(bkPath, legacyContent);
                    backupPath = bkPath;
                }
                catch {
                    continue;
                }
            }
        }
        if (inlinePlaceholder) {
            const specContent = await readFile(specMdPath, 'utf-8').catch(() => '');
            let updated = removeTopLevelSection(specContent, 'Technical');
            const filesBody = extractSectionBodyLocal(updated, 'Files');
            if (filesBody !== null && isPlaceholderTechnical(filesBody)) {
                updated = removeTopLevelSection(updated, 'Files');
            }
            if (updated !== specContent) {
                await atomicWriteFile(specMdPath, updated);
                healed += 1;
            }
            continue;
        }
        if (legacyContent !== null && isPlaceholderTechnical(legacyContent)) {
            await rm(technicalPath, { force: true });
            healed += 1;
            continue;
        }
        const isDone = spec.status === 'done' || spec.status === 'implemented';
        try {
            const wrote = await writeTechnicalMd(spec.dir, isDone, projectPath, spec.body);
            if (wrote) {
                healed += 1;
            }
        }
        catch {
            // skip
        }
    }
    // SPEC-715: Run all migrations explicitly AFTER placeholder healing
    // (so any regenerated technical.md files are also merged and cleaned up).
    // Migrations are git-aware via safeUnlink; previously they ran in list_specs (read-only).
    let leanMigratedCount = 0;
    let unifiedMigratedCount = 0;
    let rootCleanedCount = 0;
    // Only run full migrations when no specific specId is requested (mass healing)
    if (!specId && !dryRun) {
        try {
            const { migrateAllSpecsToLean } = await import('../engine/spec-migrator/lean-migration.js');
            const leanResult = await migrateAllSpecsToLean(projectPath);
            leanMigratedCount = leanResult.migratedCount;
        }
        catch {
            // best-effort
        }
        try {
            const { migrateAllSpecsToUnified } = await import('../engine/spec-migrator/unified-migration.js');
            const unifiedResult = await migrateAllSpecsToUnified(projectPath);
            unifiedMigratedCount = unifiedResult.mergedCount;
        }
        catch {
            // best-effort
        }
        try {
            const { cleanPlanuRoot } = await import('../engine/spec-migrator/planu-root-cleaner.js');
            const planuDir = join(projectPath, 'planu');
            const rootResult = await cleanPlanuRoot(planuDir, projectPath);
            rootCleanedCount = rootResult.totalDeleted;
        }
        catch {
            // best-effort
        }
    }
    const parts = [];
    parts.push(`status.json repaired: ${statusJsonRepaired ? 'yes' : 'no'}`);
    parts.push(`technical.md healed: ${String(healed)}`);
    if (dryRun) {
        parts.push('dry-run: no files written');
    }
    if (backupPath !== undefined) {
        parts.push(`backup: ${backupPath}`);
    }
    if (leanMigratedCount > 0) {
        parts.push(`lean migrations: ${String(leanMigratedCount)}`);
    }
    if (unifiedMigratedCount > 0) {
        parts.push(`unified migrations (technical.md merged+deleted): ${String(unifiedMigratedCount)}`);
    }
    if (rootCleanedCount > 0) {
        parts.push(`planu/ root cleaned: ${String(rootCleanedCount)} file(s)`);
    }
    if (titlesNonEnglish > 0) {
        parts.push(`non-English titles detected (not modified): ${String(titlesNonEnglish)}`);
    }
    const message = parts.join(' | ');
    return {
        content: [{ type: 'text', text: message }],
        isError: false,
        structuredContent: {
            changed: healed > 0,
            dryRunDiff,
            backupPath,
            warnings,
        },
    };
}
//# sourceMappingURL=heal-spec-docs.js.map