import { readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import YAML from 'yaml';
import { initializeDecisionRecords } from './init.js';
import { regenerateDecisionIndex } from './regenerate-index.js';
import { generateDecisionId } from './shared/generate-id.js';
const NOTHING_TO_MIGRATE_MESSAGE = 'Nothing to migrate; no legacy decision-record.md found.';
// Real-world `decision-record.md` files use `### ADR-NNN: Title` headings (and
// `## ADR-NNN: Title` in older fixtures), optionally without a trailing title.
// The capture groups are: [1] legacy id (e.g. `ADR-001`), [2] optional title.
const LEGACY_HEADING_PATTERN = /^#{2,3}\s+((?:ADR|DR)-\d+)(?::\s*(.+?))?\s*$/gim;
const LEGACY_ID_PATTERN = /^(?:ADR|DR)-\d+$/i;
function parseLegacyIndexRows(content) {
    const rows = [];
    for (const line of content.split(/\r?\n/)) {
        const trimmed = line.trim();
        if (!trimmed.startsWith('|')) {
            continue;
        }
        const cells = trimmed
            .split('|')
            .slice(1, -1)
            .map((cell) => cell.trim());
        const id = cells[0];
        if (!id || id === 'ID' || /^-+$/.test(id)) {
            continue;
        }
        if (!LEGACY_ID_PATTERN.test(id)) {
            continue;
        }
        // The real-world Decision Index is `| ID | Date | Status | Title |`. We use
        // it both as a parity check and as a fallback source for Date/Status/Title.
        rows.push({
            id,
            date: cells[1] || undefined,
            status: cells[2] || undefined,
            title: cells[3] || undefined,
        });
    }
    return rows;
}
function parseLegacyIndexIds(content) {
    return parseLegacyIndexRows(content).map((row) => row.id);
}
// Matches both the real-world bold form (`- **Date:** 2026-01-30`) and the
// older plain form (`- Date: 2026-01-30`). The optional `**` markers wrap the
// `Field:` label, so the colon stays inside the emphasis on the bold form.
function parseLegacyField(section, field) {
    const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const regex = new RegExp(`^-\\s*\\*{0,2}${escapedField}:\\*{0,2}\\s*(.+?)\\s*$`, 'im');
    return regex.exec(section)?.[1]?.trim() ?? null;
}
// A level-2 heading (exactly two `#`). Decision records are level-3
// (`### ADR-NNN`) inside the `## Decisions` region and their bodies use level-4
// (`#### …`) sub-headings, so any `^## ` heading after a record marks the end of
// the decisions region — e.g. a trailing `## ADR Template` boilerplate block.
const LEVEL_TWO_HEADING_PATTERN = /^##\s+(?!#)/gm;
// Returns the offset of the first `^## ` (exactly two-hash) heading that starts
// strictly after `afterIndex`, or `content.length` when none exists. Used to
// bound a decision section's body at the end of the decisions region so trailing
// `##`-level boilerplate is never absorbed into the last record.
function nextLevelTwoHeadingIndex(content, afterIndex) {
    LEVEL_TWO_HEADING_PATTERN.lastIndex = afterIndex;
    const match = LEVEL_TWO_HEADING_PATTERN.exec(content);
    return match ? match.index : content.length;
}
function parseLegacyDecisionSections(content) {
    const indexRows = new Map(parseLegacyIndexRows(content).map((row) => [row.id, row]));
    const matches = [...content.matchAll(LEGACY_HEADING_PATTERN)];
    return matches.map((match, index) => {
        const sectionStart = match.index ?? 0;
        // Bound the body at whichever comes first after this section starts: the
        // next ADR/DR heading, or the next `^## ` level-2 heading (which ends the
        // decisions region, e.g. `## ADR Template`). Default to EOF when neither
        // exists. This keeps trailing boilerplate out of the last record (F2).
        const nextRecordStart = matches[index + 1]?.index ?? content.length;
        const sectionEnd = Math.min(nextRecordStart, nextLevelTwoHeadingIndex(content, sectionStart + 1));
        const section = content.slice(sectionStart, sectionEnd).trim();
        const legacyId = match[1];
        const indexRow = indexRows.get(legacyId);
        const title = (match[2]?.trim() || indexRow?.title || legacyId).trim();
        const date = parseLegacyField(section, 'Date') ?? indexRow?.date ?? null;
        if (!date) {
            throw new Error(`Legacy decision ${legacyId} is missing a Date field.`);
        }
        return {
            legacyId,
            title,
            date,
            status: parseLegacyField(section, 'Status') ?? indexRow?.status ?? 'proposed',
            body: section,
        };
    });
}
function assertSafeLegacyDelete(legacyIndexIds, sections) {
    if (sections.length === 0) {
        throw new Error('Refusing to delete legacy decision source because no legacy decision sections were parsed.');
    }
    if (legacyIndexIds.length === 0) {
        return;
    }
    const sectionIds = new Set(sections.map((section) => section.legacyId));
    const missingSections = legacyIndexIds.filter((id) => !sectionIds.has(id));
    if (legacyIndexIds.length !== sections.length || missingSections.length > 0) {
        throw new Error(`Refusing to delete legacy decision source because parsed index count (${legacyIndexIds.length}) does not match parsed section count (${sections.length}). Missing sections: ${missingSections.join(', ') || 'none'}.`);
    }
}
function renderMigratedRecord(section, id) {
    const frontmatter = YAML.stringify({
        id,
        title: section.title,
        date: section.date,
        status: section.status,
        legacy_id: section.legacyId,
    }).trimEnd();
    return `---\n${frontmatter}\n---\n\n${section.body.trimEnd()}\n`;
}
function prepareDecisionMigrations(sections, decisionsRoot) {
    return sections.map((section) => {
        const id = generateDecisionId(section.title, section.date);
        const mapping = {
            legacyId: section.legacyId,
            id,
            title: section.title,
            date: section.date,
            filePath: join(decisionsRoot, `${id}.md`),
        };
        return {
            mapping,
            content: renderMigratedRecord(section, id),
        };
    });
}
function assertUniqueMigrationTargets(preparedMigrations) {
    const seen = new Map();
    for (const prepared of preparedMigrations) {
        const previousLegacyId = seen.get(prepared.mapping.filePath);
        if (previousLegacyId) {
            throw new Error(`Duplicate decision migration target ${prepared.mapping.filePath} generated for ${previousLegacyId} and ${prepared.mapping.legacyId}. Use unique date/title combinations before rerunning migration.`);
        }
        seen.set(prepared.mapping.filePath, prepared.mapping.legacyId);
    }
}
async function readExistingTarget(path) {
    try {
        return await readFile(path, 'utf8');
    }
    catch (error) {
        const code = error && typeof error === 'object' && 'code' in error
            ? String(error.code)
            : null;
        if (code !== 'ENOENT') {
            throw error;
        }
        return null;
    }
}
// Reads the legacy `decision-record.md`, returning `null` when the file is
// absent (rather than throwing ENOENT). The absent-file case is a legitimate
// no-op: a prior `pjm migrate --apply` already migrated decisions end-to-end
// and removed the legacy monolith, so a standalone `decision migrate` (or
// `--dry-run`) afterward should degrade gracefully (F4).
async function readLegacySource(path) {
    try {
        return await readFile(path, 'utf8');
    }
    catch (error) {
        const code = error && typeof error === 'object' && 'code' in error
            ? String(error.code)
            : null;
        if (code !== 'ENOENT') {
            throw error;
        }
        return null;
    }
}
async function findPendingWrites(preparedMigrations) {
    const pendingWrites = [];
    for (const prepared of preparedMigrations) {
        const existingContent = await readExistingTarget(prepared.mapping.filePath);
        if (existingContent === null) {
            pendingWrites.push(prepared);
            continue;
        }
        if (existingContent !== prepared.content) {
            throw new Error(`Decision migration target ${prepared.mapping.filePath} already exists with different content. Resolve or move the conflicting file before rerunning migration.`);
        }
    }
    return pendingWrites;
}
async function verifyMigratedTargets(preparedMigrations) {
    for (const prepared of preparedMigrations) {
        const existingContent = await readExistingTarget(prepared.mapping.filePath);
        if (existingContent !== prepared.content) {
            throw new Error(`Decision migration target ${prepared.mapping.filePath} was not verified after migration. Rerun without --delete-legacy after resolving the target record.`);
        }
    }
}
export async function migrateDecisionRecords(options) {
    const legacyPath = join(options.referenceRoot, 'decision-record.md');
    const decisionsRoot = join(options.referenceRoot, 'decisions');
    const legacyContent = await readLegacySource(legacyPath);
    // Absent-file no-op (F4): nothing to migrate. This is distinct from the
    // present-but-empty/unparseable case (which still flows through parsing and
    // keeps the zero-section delete-safety guard). Return a clean result for both
    // dry-run and apply instead of throwing ENOENT.
    if (legacyContent === null) {
        return {
            referenceRoot: options.referenceRoot,
            decisionsRoot,
            dryRun: options.dryRun ?? false,
            deletedLegacy: false,
            mappings: [],
            written: [],
            legacyPresent: false,
            message: NOTHING_TO_MIGRATE_MESSAGE,
        };
    }
    const legacyIndexIds = parseLegacyIndexIds(legacyContent);
    const sections = parseLegacyDecisionSections(legacyContent);
    if (options.deleteLegacy) {
        assertSafeLegacyDelete(legacyIndexIds, sections);
    }
    const preparedMigrations = prepareDecisionMigrations(sections, decisionsRoot);
    assertUniqueMigrationTargets(preparedMigrations);
    const mappings = preparedMigrations.map((prepared) => prepared.mapping);
    if (options.dryRun) {
        return {
            referenceRoot: options.referenceRoot,
            decisionsRoot,
            dryRun: true,
            deletedLegacy: false,
            mappings,
            written: [],
            legacyPresent: true,
        };
    }
    const pendingWrites = await findPendingWrites(preparedMigrations);
    await initializeDecisionRecords(decisionsRoot);
    const written = [];
    try {
        for (const prepared of pendingWrites) {
            await writeFile(prepared.mapping.filePath, prepared.content, {
                encoding: 'utf8',
                flag: 'wx',
            });
            written.push(prepared.mapping.filePath);
        }
        await regenerateDecisionIndex(decisionsRoot);
        await verifyMigratedTargets(preparedMigrations);
    }
    catch (error) {
        await Promise.all(written.map((path) => rm(path, { force: true })));
        throw error;
    }
    let deletedLegacy = false;
    if (options.deleteLegacy) {
        await rm(legacyPath);
        deletedLegacy = true;
    }
    return {
        referenceRoot: options.referenceRoot,
        decisionsRoot,
        dryRun: false,
        deletedLegacy,
        mappings,
        written,
        legacyPresent: true,
    };
}
