/**
 * Validate a bundle's facets and return a structured report.
 *
 * v1 covers four facets: catalog (derived-vs-scan consistency), schema
 * (JSON Schema validity), examples (parse via @specverse/engines/parser),
 * tests (inventory only — execution remains vitest's job). Behaviour,
 * inference, conventions, generators arrive in later iterations.
 *
 * Programmatic API for now. A `spv validate-bundle` CLI wrapper is a
 * separate concern once the underlying validation shape settles.
 *
 * See: specverse-self/docs/proposals/PLUGGABLE-BUNDLE-MODEL.md
 */
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import { basename, dirname, join, resolve } from 'path';
import { createRequire } from 'node:module';
import { spawnSync } from 'child_process';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import { deriveCatalog, clearCatalogCache } from './deriveCatalog.js';
import { parseSpecVerseFile } from '../parser/index.js';
const require = createRequire(import.meta.url);
// ── Public API ──────────────────────────────────────────────────────────
/** Validate one bundle. */
export async function validateBundle(bundleDir, options = {}) {
    const start = Date.now();
    const absDir = resolve(bundleDir);
    const name = basename(absDir);
    // Prevent a stale cache from masking source changes across repeated calls.
    clearCatalogCache();
    const schemaPath = options.schemaPath ?? resolveDefaultSchemaPath();
    const facets = [];
    facets.push(runFacet('catalog', () => checkCatalog(absDir)));
    facets.push(runFacet('schema', () => checkSchema(absDir)));
    facets.push(runFacet('examples', () => checkExamples(absDir, schemaPath)));
    facets.push(runFacet('tests', () => checkTests(absDir)));
    facets.push(runFacet('docs', () => checkDocs(absDir)));
    facets.push(runFacet('behaviour', () => checkBehaviour(absDir)));
    const totals = { pass: 0, fail: 0, skip: 0 };
    for (const f of facets)
        totals[f.status]++;
    return {
        bundle: name,
        path: absDir,
        facets,
        totals,
        duration: Date.now() - start,
    };
}
/** Validate several bundles. Reports aggregate. */
export async function validateBundles(bundleDirs, options = {}) {
    const start = Date.now();
    const bundles = [];
    for (const dir of bundleDirs) {
        bundles.push(await validateBundle(dir, options));
    }
    const totals = { pass: 0, fail: 0, skip: 0 };
    for (const b of bundles) {
        totals.pass += b.totals.pass;
        totals.fail += b.totals.fail;
        totals.skip += b.totals.skip;
    }
    return { bundles, totals, duration: Date.now() - start };
}
// ── Facet runner ────────────────────────────────────────────────────────
function runFacet(name, fn) {
    const start = Date.now();
    try {
        const partial = fn();
        return { facet: name, duration: Date.now() - start, ...partial };
    }
    catch (err) {
        return {
            facet: name,
            status: 'fail',
            duration: Date.now() - start,
            issues: [{ severity: 'error', message: `unexpected exception: ${errMsg(err)}` }],
        };
    }
}
function errMsg(err) {
    if (err instanceof Error)
        return err.message;
    return String(err);
}
// ── Per-facet checks ────────────────────────────────────────────────────
function checkCatalog(bundleDir) {
    const catalog = deriveCatalog(bundleDir);
    const issues = [];
    // Every entry's path should resolve to an actual file on disk.
    for (const entry of catalog) {
        const abs = resolve(bundleDir, entry.path.replace(/^\.\//, ''));
        if (!existsSync(abs)) {
            issues.push({
                severity: 'warning',
                message: `catalog entry references missing file: ${entry.path}`,
                location: entry.path,
            });
        }
    }
    return {
        status: issues.some((i) => i.severity === 'error') ? 'fail' : 'pass',
        issues,
        metrics: {
            total: catalog.length,
            examples: catalog.filter((e) => e.category === 'example').length,
            tests: catalog.filter((e) => e.category === 'test').length,
            behaviours: catalog.filter((e) => e.category === 'behaviour').length,
            additional: catalog.filter((e) => !['example', 'test', 'behaviour'].includes(e.category)).length,
        },
    };
}
function checkSchema(bundleDir) {
    const schemaDir = join(bundleDir, 'schema');
    if (!existsSync(schemaDir) || !statSync(schemaDir).isDirectory()) {
        return { status: 'skip', issues: [], metrics: { files: 0 } };
    }
    const files = readdirSync(schemaDir).filter((f) => f.endsWith('.json'));
    if (files.length === 0) {
        return { status: 'skip', issues: [], metrics: { files: 0 } };
    }
    const ajv = new Ajv2020.default({ strict: false, allErrors: true });
    addFormats.default(ajv);
    // Schemas under entities/src/**/schema/ use either draft-2020-12 (via the
    // Ajv2020 default) or draft-07 (historical). Register draft-07 meta-schema
    // so compiling a draft-07-declaring schema doesn't fail on a missing ref.
    try {
        const draft7 = require('ajv/dist/refs/json-schema-draft-07.json');
        if (!ajv.getSchema('http://json-schema.org/draft-07/schema')) {
            ajv.addMetaSchema(draft7);
        }
    }
    catch {
        // ajv missing the draft-07 ref shipfile — fall back to skipping
    }
    const issues = [];
    for (const file of files) {
        const fullPath = join(schemaDir, file);
        try {
            const content = readFileSync(fullPath, 'utf8');
            const schema = JSON.parse(content);
            // Compiling the schema is the validity check.
            ajv.compile(schema);
        }
        catch (err) {
            issues.push({
                severity: 'error',
                message: `schema invalid: ${errMsg(err)}`,
                location: `schema/${file}`,
            });
        }
    }
    return {
        status: issues.length === 0 ? 'pass' : 'fail',
        issues,
        metrics: { files: files.length },
    };
}
function checkExamples(bundleDir, schemaPath) {
    const examplesDir = join(bundleDir, '__examples__');
    if (!existsSync(examplesDir)) {
        return { status: 'skip', issues: [], metrics: { files: 0 } };
    }
    if (!schemaPath || !existsSync(schemaPath)) {
        return {
            status: 'skip',
            issues: [
                {
                    severity: 'warning',
                    message: 'no schema path available; skipping example parse',
                },
            ],
            metrics: { files: 0 },
        };
    }
    const examples = findFiles(examplesDir, (f) => f.endsWith('.specly'));
    const issues = [];
    const bundleName = basename(bundleDir);
    for (const file of examples) {
        try {
            const result = parseSpecVerseFile(file, schemaPath);
            if (result.errors && result.errors.length > 0) {
                for (const e of result.errors) {
                    const asAny = e;
                    issues.push({
                        severity: 'error',
                        message: typeof e === 'string' ? e : asAny?.message ?? JSON.stringify(e),
                        location: relFromBundle(file, bundleDir),
                    });
                }
                continue;
            }
            // Bundle-association check: the example should exercise the bundle's
            // entity type. Two ways to pass:
            //   1. The parsed AST has a non-empty section for the bundle's entity
            //      (`component.models`, `component.views`, etc., or root
            //      `deployments` for deployments).
            //   2. The colocated `.example.yaml` declares `concepts:` including
            //      the bundle name — this covers cases where the example
            //      exercises the entity via inference (e.g. a models-only spec
            //      that demonstrates auto-view generation in the views bundle).
            if (result.ast) {
                const structural = specContainsSection(result.ast, bundleName);
                const declared = exampleDeclaresConcept(file, bundleName);
                if (!structural && !declared) {
                    issues.push({
                        severity: 'warning',
                        message: `example in bundle '${bundleName}' doesn't exercise a '${bundleName}' section and no .example.yaml concepts claim it — verify it belongs in this bundle`,
                        location: relFromBundle(file, bundleDir),
                    });
                }
            }
        }
        catch (err) {
            issues.push({
                severity: 'error',
                message: `example failed to parse: ${errMsg(err)}`,
                location: relFromBundle(file, bundleDir),
            });
        }
    }
    return {
        status: issues.some((i) => i.severity === 'error') ? 'fail' : 'pass',
        issues,
        metrics: { files: examples.length },
    };
}
/** Check if the colocated `.example.yaml` metadata declares the given bundle
 *  as one of its concepts. This is the author's explicit claim that the
 *  example demonstrates the bundle's entity type — used as a fallback when
 *  the parsed spec doesn't directly contain a section for the bundle
 *  (typical for inference-demo examples).
 */
function exampleDeclaresConcept(specFile, bundleName) {
    const metaPath = join(dirname(specFile), basename(specFile, '.specly') + '.example.yaml');
    if (!existsSync(metaPath))
        return false;
    let content = '';
    try {
        content = readFileSync(metaPath, 'utf8');
    }
    catch {
        return false;
    }
    // Simple regex over YAML: concepts: [a, b, c] or concepts:\n  - a\n  - b
    // Match the bundle name (plural) or its singular form (strip trailing 's').
    const singular = bundleName.endsWith('s') ? bundleName.slice(0, -1) : bundleName;
    const conceptsBlock = content.match(/concepts:\s*(?:\[(.*?)\]|((?:\n\s*-\s+.*)+))/i);
    if (!conceptsBlock)
        return false;
    const raw = (conceptsBlock[1] || conceptsBlock[2] || '').toLowerCase();
    return raw.includes(bundleName.toLowerCase()) || raw.includes(singular.toLowerCase());
}
/** Check whether a parsed spec AST contains a non-empty section for the given
 *  entity name. `deployments` is at the root; everything else is under each
 *  component. Returns true if at least one example of the entity is present.
 */
function specContainsSection(ast, entityName) {
    if (entityName === 'deployments') {
        const deployments = ast?.deployments;
        if (Array.isArray(deployments))
            return deployments.length > 0;
        return !!deployments && Object.keys(deployments).length > 0;
    }
    const components = ast?.components ?? [];
    if (!Array.isArray(components))
        return false;
    for (const component of components) {
        const section = component[entityName];
        if (section == null)
            continue;
        if (Array.isArray(section) && section.length > 0)
            return true;
        if (typeof section === 'object' && Object.keys(section).length > 0)
            return true;
    }
    return false;
}
function checkTests(bundleDir) {
    const testsDir = join(bundleDir, '__tests__');
    if (!existsSync(testsDir)) {
        return { status: 'skip', issues: [], metrics: { files: 0 } };
    }
    const TEST_RE = /\.(test|spec)\.(ts|tsx|js|mjs)$/;
    const tests = findFiles(testsDir, (f) => TEST_RE.test(f));
    const issues = [];
    // Bundle-import heuristic: each test file should reach back into its
    // bundle — either `import … from '../<sibling>.js'` or '../index.js' or
    // a relative path under '../'. If a test imports nothing from '../', it
    // may be miscategorised.
    for (const file of tests) {
        let content = '';
        try {
            content = readFileSync(file, 'utf8');
        }
        catch {
            continue;
        }
        const bundleLocal = /from\s+['"]\.\.\//.test(content);
        if (!bundleLocal) {
            issues.push({
                severity: 'warning',
                message: `test does not import from '../' — may not belong to this bundle`,
                location: relFromBundle(file, bundleDir),
            });
        }
    }
    return {
        status: issues.some((i) => i.severity === 'error') ? 'fail' : 'pass',
        issues,
        metrics: {
            files: tests.length,
            hint: tests.length > 0 ? `run with: npx vitest run ${bundleDir}` : '',
        },
    };
}
/** Verify that every .specly in __examples__/ has a colocated .md whose
 *  content has an H1 and at least mentions the bundle's entity type.
 *  Weak check — catches obvious misfiles + forgotten-doc situations.
 */
function checkDocs(bundleDir) {
    const examplesDir = join(bundleDir, '__examples__');
    if (!existsSync(examplesDir)) {
        return { status: 'skip', issues: [], metrics: { files: 0 } };
    }
    const specs = findFiles(examplesDir, (f) => f.endsWith('.specly'));
    const bundleName = basename(bundleDir).toLowerCase();
    const issues = [];
    let docsChecked = 0;
    for (const spec of specs) {
        const mdPath = join(dirname(spec), basename(spec, '.specly') + '.md');
        if (!existsSync(mdPath)) {
            issues.push({
                severity: 'warning',
                message: `no colocated .md next to ${basename(spec)}`,
                location: relFromBundle(spec, bundleDir),
            });
            continue;
        }
        docsChecked += 1;
        let content = '';
        try {
            content = readFileSync(mdPath, 'utf8');
        }
        catch {
            issues.push({
                severity: 'error',
                message: `unreadable .md`,
                location: relFromBundle(mdPath, bundleDir),
            });
            continue;
        }
        if (!/^#\s+/m.test(content)) {
            issues.push({
                severity: 'warning',
                message: `.md missing an H1 heading`,
                location: relFromBundle(mdPath, bundleDir),
            });
        }
        // Mentions-bundle heuristic: plural (bundle dir name) OR singular
        // (strip trailing 's') appears at least once, case-insensitive.
        const singular = bundleName.endsWith('s') ? bundleName.slice(0, -1) : bundleName;
        const lower = content.toLowerCase();
        if (!lower.includes(bundleName) && !lower.includes(singular)) {
            issues.push({
                severity: 'warning',
                message: `.md doesn't mention '${bundleName}' (or '${singular}') — doc may not describe this bundle`,
                location: relFromBundle(mdPath, bundleDir),
            });
        }
    }
    return {
        status: issues.some((i) => i.severity === 'error') ? 'fail' : 'pass',
        issues,
        metrics: { docs: docsChecked, specs: specs.length },
    };
}
/** Run `quint typecheck` on every .qnt file in __behaviour__/. Skips with a
 *  warning if quint isn't on PATH. Full `quint verify` (safety/liveness
 *  proof) is a future extension — typecheck catches syntax + shape errors
 *  cheaply in milliseconds per file.
 */
function checkBehaviour(bundleDir) {
    const behaviourDir = join(bundleDir, '__behaviour__');
    if (!existsSync(behaviourDir)) {
        return { status: 'skip', issues: [], metrics: { files: 0 } };
    }
    const qntFiles = findFiles(behaviourDir, (f) => f.endsWith('.qnt'));
    if (qntFiles.length === 0) {
        return { status: 'skip', issues: [], metrics: { files: 0 } };
    }
    // Probe quint availability once.
    const probe = spawnSync('quint', ['--version'], { encoding: 'utf8' });
    if (probe.error || probe.status !== 0) {
        return {
            status: 'skip',
            issues: [
                {
                    severity: 'warning',
                    message: 'quint CLI not on PATH — install via npm install -g @informalsystems/quint',
                },
            ],
            metrics: { files: qntFiles.length },
        };
    }
    const issues = [];
    for (const file of qntFiles) {
        const res = spawnSync('quint', ['typecheck', file], {
            encoding: 'utf8',
            timeout: 15000,
        });
        if (res.status !== 0) {
            issues.push({
                severity: 'error',
                message: `quint typecheck failed: ${(res.stderr || res.stdout || '').slice(0, 200).trim()}`,
                location: relFromBundle(file, bundleDir),
            });
        }
    }
    return {
        status: issues.some((i) => i.severity === 'error') ? 'fail' : 'pass',
        issues,
        metrics: { files: qntFiles.length },
    };
}
// ── Utilities ───────────────────────────────────────────────────────────
function findFiles(dir, match) {
    const out = [];
    const walk = (d) => {
        let entries;
        try {
            entries = readdirSync(d);
        }
        catch {
            return;
        }
        for (const entry of entries) {
            const full = join(d, entry);
            let s;
            try {
                s = statSync(full);
            }
            catch {
                continue;
            }
            if (s.isDirectory())
                walk(full);
            else if (s.isFile() && match(entry))
                out.push(full);
        }
    };
    walk(dir);
    return out;
}
function relFromBundle(filePath, bundleDir) {
    if (filePath.startsWith(bundleDir)) {
        return './' + filePath.slice(bundleDir.length + 1).replaceAll('\\', '/');
    }
    return filePath;
}
function resolveDefaultSchemaPath() {
    try {
        const pkg = require.resolve('@specverse/entities/package.json');
        const schemaPath = join(pkg, '..', 'schema', 'SPECVERSE-SCHEMA.json');
        return existsSync(schemaPath) ? schemaPath : null;
    }
    catch {
        return null;
    }
}
//# sourceMappingURL=validate.js.map