import { promises as fs } from 'fs';
import path from 'path';
import { JsonConverter } from '../core/converters/json-converter.js';
import { Validator } from '../core/validation/validator.js';
import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
import { ChangeParser } from '../core/parsers/change-parser.js';
import { isInteractive } from '../utils/interactive.js';
import { getActiveChangeIds } from '../utils/item-discovery.js';
import { getTaskProgressForChange } from '../utils/task-progress.js';
import { FileSystemUtils } from '../utils/file-system.js';
/**
 * True only when `target` is definitively absent. An EACCES or I/O failure
 * means existence cannot be determined, so callers fall through to their
 * read-error path rather than claim the file was never written.
 */
async function isDefinitelyMissing(target) {
    return fs
        .access(target)
        .then(() => false)
        .catch((error) => error?.code === 'ENOENT');
}
/**
 * A change is a directory directly under changes/. Rejecting anything else up
 * front keeps a traversing name (`../..`) from reading a proposal outside the
 * changes directory, and keeps the missing-proposal message honest.
 */
function isChangeDirectoryName(changesPath, changeDir) {
    return path.dirname(path.resolve(changeDir)) === path.resolve(changesPath);
}
export class ChangeCommand {
    converter;
    rootPath;
    // rootPath is set only by root-aware callers (top-level `show`); the
    // deprecated noun-form commands stay cwd-based.
    constructor(rootPath) {
        this.converter = new JsonConverter();
        this.rootPath = rootPath;
    }
    getChangesPath() {
        return path.join(this.rootPath ?? process.cwd(), 'openspec', 'changes');
    }
    /**
     * Show a change proposal.
     * - Text mode: raw markdown passthrough (no filters)
     * - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas
     *   Note: --requirements-only is deprecated alias for --deltas-only
     */
    async show(changeName, options) {
        const changesPath = this.getChangesPath();
        if (!changeName) {
            const canPrompt = isInteractive(options);
            // Offer exactly the changes `show <name>` can resolve.
            const changes = await getActiveChangeIds(this.rootPath ?? process.cwd());
            if (canPrompt && changes.length > 0) {
                const { select } = await import('@inquirer/prompts');
                const selected = await select({
                    message: 'Select a change to show',
                    choices: changes.map(id => ({ name: id, value: id })),
                });
                changeName = selected;
            }
            else {
                if (changes.length === 0) {
                    console.error('No change specified. No active changes found.');
                }
                else {
                    console.error(`No change specified. Available IDs: ${changes.join(', ')}`);
                }
                console.error('Hint: use "openspec change list" to view available changes.');
                process.exitCode = 1;
                return;
            }
        }
        const changeDir = path.join(changesPath, changeName);
        const proposalPath = path.join(changeDir, 'proposal.md');
        if (!isChangeDirectoryName(changesPath, changeDir)) {
            throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
        }
        try {
            await fs.access(proposalPath);
        }
        catch {
            // A change can exist without a proposal: `openspec new change` scaffolds
            // only .openspec.yaml, and a custom schema need not define a proposal
            // artifact. Say which of the two cases this is instead of reporting a
            // change that does exist as missing. A stray file under changes/ is not a
            // change, and naming it one would point the user at a `status --change`
            // call that cannot work.
            const isChangeDirectory = await fs
                .stat(changeDir)
                .then((stats) => stats.isDirectory())
                .catch(() => false);
            if (isChangeDirectory) {
                throw new Error(`Change "${changeName}" has no proposal.md yet. ` +
                    `Run "openspec status --change ${changeName}" to see which artifact comes next.`);
            }
            throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
        }
        FileSystemUtils.assertPathWithin(path.dirname(proposalPath), proposalPath);
        if (options?.json) {
            FileSystemUtils.assertPathWithin(changeDir, proposalPath);
            const jsonOutput = await this.converter.convertChangeToJson(proposalPath);
            if (options.requirementsOnly) {
                console.error('Flag --requirements-only is deprecated; use --deltas-only instead.');
            }
            const parsed = JSON.parse(jsonOutput);
            FileSystemUtils.assertPathWithin(changeDir, proposalPath);
            const contentForTitle = await fs.readFile(proposalPath, 'utf-8');
            const title = this.extractTitle(contentForTitle, changeName);
            const id = parsed.name;
            const deltas = parsed.deltas || [];
            const output = {
                id,
                title,
                deltaCount: deltas.length,
                deltas,
                ...(options.rootOutput ? { root: options.rootOutput } : {}),
            };
            console.log(JSON.stringify(output, null, 2));
        }
        else {
            FileSystemUtils.assertPathWithin(changeDir, proposalPath);
            const content = await fs.readFile(proposalPath, 'utf-8');
            console.log(content);
        }
    }
    /**
     * List active changes.
     * - Text default: IDs only; --long prints minimal details (title, counts)
     * - JSON: array of { id, title, deltaCount, taskStatus }, sorted by id
     */
    async list(options) {
        const changesPath = path.join(process.cwd(), 'openspec', 'changes');
        // Same directory-based resolution as `openspec list`, the command this
        // deprecated alias points users at. Every output path below already
        // tolerates a change whose proposal.md is missing or unreadable.
        const changes = await getActiveChangeIds();
        if (options?.json) {
            const changeDetails = await Promise.all(changes.map(async (changeName) => {
                const changeDir = path.join(changesPath, changeName);
                const proposalPath = path.join(changeDir, 'proposal.md');
                // Resolve task progress through the shared tracked-tasks helper so
                // this deprecated noun-form list cannot re-fork the resolution
                // (#1202). Tasks are independent of the proposal: a change can carry
                // tasks before, or without, a proposal.md.
                const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd());
                // No proposal yet is an ordinary state (scaffolded change, or a
                // schema with no proposal artifact), so name the change rather than
                // labelling it Unknown. Unknown stays for a proposal that exists but
                // cannot be read or parsed.
                if (await isDefinitelyMissing(proposalPath)) {
                    return { id: changeName, title: changeName, deltaCount: 0, taskStatus };
                }
                try {
                    FileSystemUtils.assertPathWithin(changeDir, proposalPath);
                    const content = await fs.readFile(proposalPath, 'utf-8');
                    const parser = new ChangeParser(content, changeDir);
                    const change = await parser.parseChangeWithDeltas(changeName);
                    return {
                        id: changeName,
                        title: this.extractTitle(content, changeName),
                        deltaCount: change.deltas.length,
                        taskStatus,
                    };
                }
                catch {
                    return { id: changeName, title: 'Unknown', deltaCount: 0, taskStatus };
                }
            }));
            const sorted = changeDetails.sort((a, b) => a.id.localeCompare(b.id));
            console.log(JSON.stringify(sorted, null, 2));
        }
        else {
            if (changes.length === 0) {
                console.log('No items found');
                return;
            }
            const sorted = [...changes].sort();
            if (!options?.long) {
                // IDs only
                sorted.forEach(id => console.log(id));
                return;
            }
            // Long format: id: title and minimal counts
            for (const changeName of sorted) {
                const changeDir = path.join(changesPath, changeName);
                const proposalPath = path.join(changeDir, 'proposal.md');
                const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd());
                const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : '';
                if (await isDefinitelyMissing(proposalPath)) {
                    console.log(`${changeName}: (no proposal.md yet)${taskStatusText}`);
                    continue;
                }
                try {
                    FileSystemUtils.assertPathWithin(changeDir, proposalPath);
                    const content = await fs.readFile(proposalPath, 'utf-8');
                    const title = this.extractTitle(content, changeName);
                    const parser = new ChangeParser(content, changeDir);
                    const change = await parser.parseChangeWithDeltas(changeName);
                    const deltaCountText = ` [deltas ${change.deltas.length}]`;
                    console.log(`${changeName}: ${title}${deltaCountText}${taskStatusText}`);
                }
                catch {
                    console.log(`${changeName}: (unable to read)${taskStatusText}`);
                }
            }
        }
    }
    async validate(changeName, options) {
        const changesPath = path.join(process.cwd(), 'openspec', 'changes');
        if (!changeName) {
            const canPrompt = isInteractive(options);
            const changes = await getActiveChangeIds();
            if (canPrompt && changes.length > 0) {
                const { select } = await import('@inquirer/prompts');
                const selected = await select({
                    message: 'Select a change to validate',
                    choices: changes.map(id => ({ name: id, value: id })),
                });
                changeName = selected;
            }
            else {
                if (changes.length === 0) {
                    console.error('No change specified. No active changes found.');
                }
                else {
                    console.error(`No change specified. Available IDs: ${changes.join(', ')}`);
                }
                console.error('Hint: use "openspec change list" to view available changes.');
                process.exitCode = 1;
                return;
            }
        }
        const changeDir = path.join(changesPath, changeName);
        if (!isChangeDirectoryName(changesPath, changeDir)) {
            throw new Error(`Change "${changeName}" not found at ${changeDir}`);
        }
        try {
            await fs.access(changeDir);
        }
        catch {
            throw new Error(`Change "${changeName}" not found at ${changeDir}`);
        }
        const validator = new Validator(options?.strict || false);
        const report = await validator.validateChangeDeltaSpecs(changeDir, {
            // Derived from changesPath so the main specs come from the same root the
            // change itself was resolved against.
            mainSpecsDir: path.join(path.dirname(changesPath), 'specs'),
        });
        if (options?.json) {
            console.log(JSON.stringify(report, null, 2));
        }
        else {
            if (report.valid) {
                console.log(`Change "${changeName}" is valid`);
            }
            else {
                console.error(`Change "${changeName}" has issues`);
                report.issues.forEach(issue => {
                    const label = issue.level === 'ERROR' ? 'ERROR' : 'WARNING';
                    const prefix = issue.level === 'ERROR' ? '✗' : '⚠';
                    console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
                });
                // Next steps footer to guide fixing issues
                this.printNextSteps(report.issues);
                if (!options?.json) {
                    process.exitCode = 1;
                }
            }
        }
    }
    extractTitle(content, changeName) {
        const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im);
        return match ? match[1].trim() : changeName;
    }
    printNextSteps(issues = []) {
        const bullets = [];
        // Branch on the exact marker messages: the generic no-deltas guidance
        // also mentions skip_specs and must not trigger the marker bullets.
        const conflictIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT));
        const invalidMarkerIssue = issues.some(i => i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA));
        if (conflictIssue) {
            bullets.push('- This change declares skip_specs (no spec deltas): delete the files under specs/, or remove skip_specs from .openspec.yaml if requirements do change');
            bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: <name> is required)');
        }
        else if (invalidMarkerIssue) {
            bullets.push('- Fix .openspec.yaml so the skip_specs marker can be honored (schema: <name> is required)');
            bullets.push('- Or remove skip_specs from .openspec.yaml and add delta specs instead');
        }
        else {
            bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements');
            bullets.push('- Each requirement MUST include at least one #### Scenario: block');
            bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only');
        }
        console.error('Next steps:');
        bullets.forEach(b => console.error(`  ${b}`));
    }
}
//# sourceMappingURL=change.js.map