// NERF DB Writer — Markdown generators + DB-first write helpers
//
// The missing DB→markdown direction. S03 established markdown→DB (md-importer.ts).
// This module generates DECISIONS.md and REQUIREMENTS.md from DB state,
// computes next decision IDs, and provides write helpers that upsert to DB
// then regenerate the corresponding markdown file.
//
// Critical invariant: generated markdown must round-trip through
// parseDecisionsTable() and parseRequirementsSections() with field fidelity.
import { resolve } from 'node:path';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { resolveNerfRootFile } from './paths.js';
import { saveFile } from './files.js';
import { NerfError, NERF_STALE_STATE, NERF_IO_ERROR } from './errors.js';
import { logWarning, logError } from './workflow-logger.js';
import { invalidateStateCache } from './state.js';
import { clearPathCache } from './paths.js';
import { clearParseCache } from './files.js';
// ─── Freeform Detection ───────────────────────────────────────────────────
/**
 * Detect whether a DECISIONS.md file is in canonical table format
 * (generated by generateDecisionsMd).
 *
 * Returns true only if the file starts with the canonical header
 * ("# Decisions Register") that generateDecisionsMd produces.
 * Files with freeform content — even if they contain an appended
 * decisions table section — return false so the freeform content
 * is preserved.
 */
export function isDecisionsTableFormat(content) {
    // The canonical format always starts with "# Decisions Register"
    const firstLine = content.split('\n')[0]?.trim() ?? '';
    if (firstLine !== '# Decisions Register')
        return false;
    // Additionally verify the file has the canonical table header
    return content.includes('| # | When | Scope | Decision | Choice | Rationale | Revisable?');
}
/**
 * Generate a minimal decisions table section (header + rows) for appending
 * to a freeform DECISIONS.md file.
 */
function generateDecisionsAppendBlock(decisions) {
    const lines = [];
    lines.push('');
    lines.push('---');
    lines.push('');
    lines.push('## Decisions Table');
    lines.push('');
    lines.push('| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |');
    lines.push('|---|------|-------|----------|--------|-----------|------------|---------|');
    for (const d of decisions) {
        const cells = [
            d.id,
            d.when_context,
            d.scope,
            d.decision,
            d.choice,
            d.rationale,
            d.revisable,
            d.made_by ?? 'agent',
        ].map(cell => (cell ?? '').replace(/\|/g, '\\|'));
        lines.push(`| ${cells.join(' | ')} |`);
    }
    return lines.join('\n') + '\n';
}
// ─── Markdown Generators ──────────────────────────────────────────────────
/**
 * Generate full DECISIONS.md content from an array of Decision objects.
 * Produces the canonical format: H1 header, HTML comment block, table header,
 * separator, and one data row per decision.
 *
 * Column order: #, When, Scope, Decision, Choice, Rationale, Revisable?
 */
export function generateDecisionsMd(decisions) {
    const lines = [];
    lines.push('# Decisions Register');
    lines.push('');
    lines.push('<!-- Append-only. Never edit or remove existing rows.');
    lines.push('     To reverse a decision, add a new row that supersedes it.');
    lines.push('     Read this file at the start of any planning or research phase. -->');
    lines.push('');
    lines.push('| # | When | Scope | Decision | Choice | Rationale | Revisable? | Made By |');
    lines.push('|---|------|-------|----------|--------|-----------|------------|---------|');
    for (const d of decisions) {
        // Escape pipe characters within cell values to preserve table structure
        const cells = [
            d.id,
            d.when_context,
            d.scope,
            d.decision,
            d.choice,
            d.rationale,
            d.revisable,
            d.made_by ?? 'agent',
        ].map(cell => (cell ?? '').replace(/\|/g, '\\|'));
        lines.push(`| ${cells.join(' | ')} |`);
    }
    return lines.join('\n') + '\n';
}
// ─── Requirements Markdown Generator ──────────────────────────────────────
/** Status values that map to specific sections, in display order. */
const STATUS_SECTION_MAP = [
    { status: 'active', heading: 'Active' },
    { status: 'validated', heading: 'Validated' },
    { status: 'deferred', heading: 'Deferred' },
    { status: 'out-of-scope', heading: 'Out of Scope' },
];
/**
 * Generate full REQUIREMENTS.md content from an array of Requirement objects.
 * Groups requirements by status into sections (## Active, ## Validated, etc.),
 * each containing ### RXXX — Description headings with bullet fields.
 * Only emits sections that have content. Appends Traceability table and
 * Coverage Summary at the bottom.
 */
export function generateRequirementsMd(requirements) {
    const lines = [];
    lines.push('# Requirements');
    lines.push('');
    lines.push('This file is the explicit capability and coverage contract for the project.');
    lines.push('');
    // Group by status
    const byStatus = new Map();
    for (const r of requirements) {
        const status = (r.status || 'active').toLowerCase();
        if (!byStatus.has(status))
            byStatus.set(status, []);
        byStatus.get(status).push(r);
    }
    // Emit sections in canonical order
    for (const { status, heading } of STATUS_SECTION_MAP) {
        const reqs = byStatus.get(status);
        if (!reqs || reqs.length === 0)
            continue;
        lines.push(`## ${heading}`);
        lines.push('');
        for (const r of reqs) {
            lines.push(`### ${r.id} — ${r.description || 'Untitled'}`);
            // Emit bullet fields — only those with content
            if (r.class)
                lines.push(`- Class: ${r.class}`);
            if (r.status)
                lines.push(`- Status: ${r.status}`);
            if (r.description)
                lines.push(`- Description: ${r.description}`);
            if (r.why)
                lines.push(`- Why it matters: ${r.why}`);
            if (r.source)
                lines.push(`- Source: ${r.source}`);
            if (r.primary_owner)
                lines.push(`- Primary owning slice: ${r.primary_owner}`);
            if (r.supporting_slices)
                lines.push(`- Supporting slices: ${r.supporting_slices}`);
            if (r.validation)
                lines.push(`- Validation: ${r.validation}`);
            if (r.notes)
                lines.push(`- Notes: ${r.notes}`);
            lines.push('');
        }
    }
    // Traceability table
    lines.push('## Traceability');
    lines.push('');
    lines.push('| ID | Class | Status | Primary owner | Supporting | Proof |');
    lines.push('|---|---|---|---|---|---|');
    for (const r of requirements) {
        const proof = r.validation || 'unmapped';
        lines.push(`| ${r.id} | ${r.class || ''} | ${r.status || ''} | ${r.primary_owner || 'none'} | ${r.supporting_slices || 'none'} | ${proof} |`);
    }
    lines.push('');
    // Coverage Summary
    const activeCount = byStatus.get('active')?.length ?? 0;
    const validatedReqs = byStatus.get('validated') ?? [];
    const validatedIds = validatedReqs.map(r => r.id).join(', ');
    lines.push('## Coverage Summary');
    lines.push('');
    lines.push(`- Active requirements: ${activeCount}`);
    lines.push(`- Mapped to slices: ${activeCount}`);
    lines.push(`- Validated: ${validatedReqs.length}${validatedIds ? ` (${validatedIds})` : ''}`);
    lines.push(`- Unmapped active requirements: 0`);
    return lines.join('\n') + '\n';
}
// ─── Next Decision ID ─────────────────────────────────────────────────────
/**
 * Compute the next decision ID from the current DB state.
 * Queries MAX(CAST(SUBSTR(id, 2) AS INTEGER)) from decisions table.
 * Returns D001 if no decisions exist. Zero-pads to 3 digits.
 */
export async function nextDecisionId() {
    try {
        const db = await import('./nerf-db.js');
        const adapter = db._getAdapter();
        if (!adapter)
            return 'D001';
        const row = adapter
            .prepare('SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM decisions')
            .get();
        const maxNum = row ? row['max_num'] : null;
        if (maxNum == null || isNaN(maxNum))
            return 'D001';
        const next = maxNum + 1;
        return `D${String(next).padStart(3, '0')}`;
    }
    catch (err) {
        logError('manifest', 'nextDecisionId failed', { fn: 'nextDecisionId', error: String(err.message) });
        return 'D001';
    }
}
/**
 * Save a new decision to DB and regenerate DECISIONS.md.
 * Auto-assigns the next ID via nextDecisionId().
 * Returns the assigned ID.
 */
export async function saveDecisionToDb(fields, basePath) {
    try {
        const db = await import('./nerf-db.js');
        const id = await nextDecisionId();
        db.upsertDecision({
            id,
            when_context: fields.when_context ?? '',
            scope: fields.scope,
            decision: fields.decision,
            choice: fields.choice,
            rationale: fields.rationale,
            revisable: fields.revisable ?? 'Yes',
            made_by: fields.made_by ?? 'agent',
            superseded_by: null,
        });
        // Fetch all decisions (including superseded for the full register)
        const adapter = db._getAdapter();
        let allDecisions = [];
        if (adapter) {
            const rows = adapter.prepare('SELECT * FROM decisions ORDER BY seq').all();
            allDecisions = rows.map(row => ({
                seq: row['seq'],
                id: row['id'],
                when_context: row['when_context'],
                scope: row['scope'],
                decision: row['decision'],
                choice: row['choice'],
                rationale: row['rationale'],
                revisable: row['revisable'],
                made_by: row['made_by'] ?? 'agent',
                superseded_by: row['superseded_by'] ?? null,
            }));
        }
        const filePath = resolveNerfRootFile(basePath, 'DECISIONS');
        // Check if existing DECISIONS.md has freeform (non-table) content.
        // If so, preserve that content and append/update the decisions table
        // at the end instead of overwriting the entire file.
        let existingContent = null;
        if (existsSync(filePath)) {
            existingContent = readFileSync(filePath, 'utf-8');
        }
        let md;
        if (existingContent && !isDecisionsTableFormat(existingContent)) {
            // Freeform content detected — preserve it and append decisions table.
            // Strip any previously appended decisions table section to avoid duplication.
            const marker = '---\n\n## Decisions Table';
            const markerIdx = existingContent.indexOf(marker);
            const freeformPart = markerIdx >= 0
                ? existingContent.substring(0, markerIdx).trimEnd()
                : existingContent.trimEnd();
            md = freeformPart + '\n' + generateDecisionsAppendBlock(allDecisions);
        }
        else {
            // Table format or no existing file — full regeneration (original behavior)
            md = generateDecisionsMd(allDecisions);
        }
        try {
            await saveFile(filePath, md);
        }
        catch (diskErr) {
            logError('manifest', 'disk write failed, rolling back DB row', { fn: 'saveDecisionToDb', error: String(diskErr.message) });
            adapter?.prepare('DELETE FROM decisions WHERE id = :id').run({ ':id': id });
            throw diskErr;
        }
        // Invalidate file-read caches so deriveState() sees the updated markdown.
        // Do NOT clear the artifacts table — we just wrote to it intentionally.
        invalidateStateCache();
        clearPathCache();
        clearParseCache();
        return { id };
    }
    catch (err) {
        logError('manifest', 'saveDecisionToDb failed', { fn: 'saveDecisionToDb', error: String(err.message) });
        throw err;
    }
}
// ─── Update Requirement in DB + Regenerate Markdown ───────────────────────
/**
 * Update a requirement in DB and regenerate REQUIREMENTS.md.
 * Fetches existing requirement, merges updates, upserts, then regenerates.
 */
export async function updateRequirementInDb(id, updates, basePath) {
    try {
        const db = await import('./nerf-db.js');
        const existing = db.getRequirementById(id);
        if (!existing) {
            throw new NerfError(NERF_STALE_STATE, `Requirement ${id} not found`);
        }
        // Merge updates into existing
        const merged = {
            ...existing,
            ...updates,
            id: existing.id, // ID cannot be changed
        };
        db.upsertRequirement(merged);
        // Fetch ALL requirements (including superseded) for full file regeneration
        const adapter = db._getAdapter();
        let allRequirements = [];
        if (adapter) {
            const rows = adapter.prepare('SELECT * FROM requirements ORDER BY id').all();
            allRequirements = rows.map(row => ({
                id: row['id'],
                class: row['class'],
                status: row['status'],
                description: row['description'],
                why: row['why'],
                source: row['source'],
                primary_owner: row['primary_owner'],
                supporting_slices: row['supporting_slices'],
                validation: row['validation'],
                notes: row['notes'],
                full_content: row['full_content'],
                superseded_by: row['superseded_by'] ?? null,
            }));
        }
        // Filter to non-superseded for the markdown file
        // (superseded requirements don't appear in section headings)
        const nonSuperseded = allRequirements.filter(r => r.superseded_by == null);
        const md = generateRequirementsMd(nonSuperseded);
        const filePath = resolveNerfRootFile(basePath, 'REQUIREMENTS');
        try {
            await saveFile(filePath, md);
        }
        catch (diskErr) {
            logError('manifest', 'disk write failed, reverting DB row', { fn: 'updateRequirementInDb', error: String(diskErr.message) });
            db.upsertRequirement(existing);
            throw diskErr;
        }
        // Invalidate file-read caches so deriveState() sees the updated markdown.
        // Do NOT clear the artifacts table — we just wrote to it intentionally.
        invalidateStateCache();
        clearPathCache();
        clearParseCache();
    }
    catch (err) {
        logError('manifest', 'updateRequirementInDb failed', { fn: 'updateRequirementInDb', error: String(err.message) });
        throw err;
    }
}
/**
 * Save an artifact to DB and write the corresponding markdown file to disk.
 * The path is relative to .nerf/ (e.g. "milestones/M001/slices/S06/tasks/T01-SUMMARY.md").
 * The full file path is computed as basePath + '.nerf/' + path.
 */
export async function saveArtifactToDb(opts, basePath) {
    try {
        const db = await import('./nerf-db.js');
        // Guard against path traversal before any reads/writes
        const nerfDir = resolve(basePath, '.nerf');
        const fullPath = resolve(basePath, '.nerf', opts.path);
        if (!fullPath.startsWith(nerfDir)) {
            throw new NerfError(NERF_IO_ERROR, `saveArtifactToDb: path escapes .nerf/ directory: ${opts.path}`);
        }
        // Shrinkage guard: if the file already exists and the new content is
        // significantly smaller (<50%), preserve the richer file on disk and
        // store its content in the DB instead of the abbreviated version.
        let dbContent = opts.content;
        let skipDiskWrite = false;
        if (existsSync(fullPath)) {
            const existingSize = statSync(fullPath).size;
            const newSize = Buffer.byteLength(opts.content, 'utf-8');
            if (existingSize > 0 && newSize < existingSize * 0.5) {
                logWarning('manifest', `new content (${newSize}B) is <50% of existing file (${existingSize}B), preserving disk file`, { fn: 'saveArtifactToDb', path: opts.path });
                dbContent = readFileSync(fullPath, 'utf-8');
                skipDiskWrite = true;
            }
        }
        db.insertArtifact({
            path: opts.path,
            artifact_type: opts.artifact_type,
            milestone_id: opts.milestone_id ?? null,
            slice_id: opts.slice_id ?? null,
            task_id: opts.task_id ?? null,
            full_content: dbContent,
        });
        // Write the file to disk (only if we're not preserving a richer existing file)
        if (!skipDiskWrite) {
            try {
                await saveFile(fullPath, opts.content);
            }
            catch (diskErr) {
                logError('manifest', 'disk write failed, rolling back DB row', { fn: 'saveArtifactToDb', error: String(diskErr.message) });
                const rollbackAdapter = db._getAdapter();
                rollbackAdapter?.prepare('DELETE FROM artifacts WHERE path = :path').run({ ':path': opts.path });
                throw diskErr;
            }
        }
        // Invalidate file-read caches so deriveState() sees the updated markdown.
        // Do NOT clear the artifacts table — we just wrote to it intentionally.
        invalidateStateCache();
        clearPathCache();
        clearParseCache();
    }
    catch (err) {
        logError('manifest', 'saveArtifactToDb failed', { fn: 'saveArtifactToDb', error: String(err.message) });
        throw err;
    }
}
