/**
 * docs/features-reader.mjs — deterministic reader for `.aperant/features/`
 * (C49).
 *
 * The feature registry is the primary input for `/apt:create-docs`. This
 * module reads every per-area JSON file in `.aperant/features/` and returns a
 * structured inventory the apt-docs-author agent can consume. It is PURE:
 *
 *   - No LLM calls.
 *   - No side effects (no writes).
 *   - No argv / stdout dependencies.
 *
 * Output contract (C49 deliverable 4):
 *
 *   {
 *     status: 'ok' | 'empty' | 'missing',
 *     registryDir,
 *     areas: FeatureArea[],
 *     totals: { areas, features, subFeatures, wired, stubbed, unwired }
 *   }
 *
 * Failure modes we explicitly tolerate:
 *
 *   - `.aperant/features/` missing entirely → `status: 'missing'`, `areas: []`.
 *     The skill body branches on this and asks the user to run `/apt:scan`
 *     first.
 *   - Empty directory (no per-area JSON) → `status: 'empty'`, `areas: []`.
 *   - A single area JSON malformed → that area is dropped with an error
 *     entry on `parse_errors[]`; the rest of the inventory still loads.
 *     Partial loads are better than blanket failure — users with a 16-area
 *     registry shouldn't lose 15 areas because one is broken.
 *
 * Wiring-status normalization (D-03):
 *
 *   - Area-level: the area JSONs use a variety of shapes (`wired` on
 *     each feature / sub_feature, sometimes as bool, sometimes as string
 *     enum 'stubbed' | 'partial'). We normalize to three buckets that
 *     map to the D-03 rules:
 *       'wired'   — sub_feature.wired === true
 *       'stubbed' — sub_feature.wired === 'stubbed' || 'partial'
 *       'unwired' — sub_feature.wired === false  (display-only / mock)
 *     Anything else falls to 'unwired' defensively — an unknown token
 *     is safer categorized as "don't ship docs for it" than as wired.
 */
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';
/**
 * @typedef {'wired' | 'stubbed' | 'unwired'} WiringBucket
 */
/**
 * @typedef {Object} SubFeature
 * @property {string} id
 * @property {string} name
 * @property {WiringBucket} wiring
 * @property {string} [component]
 * @property {string} [type]
 * @property {string} [handler]
 * @property {string} [test_hint]
 */
/**
 * @typedef {Object} FeatureGroup
 * @property {string} id
 * @property {string} name
 * @property {WiringBucket} wiring
 * @property {string} [component]
 * @property {string} [type]
 * @property {SubFeature[]} sub_features
 */
/**
 * @typedef {Object} FeatureArea
 * @property {string} area             kebab-case area id (e.g. "terminals")
 * @property {string} [route]
 * @property {string} [status]
 * @property {string} [last_scanned]
 * @property {FeatureGroup[]} features
 * @property {{ wired: number, stubbed: number, unwired: number, total: number }} counts
 * @property {string} source_path       absolute path the area was read from
 */
/**
 * @typedef {Object} InventoryResult
 * @property {'ok' | 'empty' | 'missing'} status
 * @property {string} registryDir
 * @property {FeatureArea[]} areas
 * @property {{ areas: number, features: number, sub_features: number, wired: number, stubbed: number, unwired: number }} totals
 * @property {Array<{ file: string, message: string }>} parse_errors
 */
/**
 * Normalize the wiring signal on a feature or sub-feature.
 *
 * @param {unknown} wired
 * @returns {WiringBucket}
 */
export function normalizeWiring(wired) {
    if (wired === true)
        return 'wired';
    if (wired === 'stubbed' || wired === 'partial')
        return 'stubbed';
    return 'unwired';
}
/**
 * Project a JSON sub-feature into the normalized shape the apt-docs-author
 * agent consumes.
 *
 * @param {unknown} raw
 * @returns {SubFeature | null}
 */
function projectSubFeature(raw) {
    if (!raw || typeof raw !== 'object')
        return null;
    const r = /** @type {Record<string, unknown>} */ (raw);
    if (typeof r.id !== 'string' || typeof r.name !== 'string')
        return null;
    const sf = {
        id: r.id,
        name: r.name,
        wiring: normalizeWiring(r.wired),
    };
    if (typeof r.component === 'string')
        sf.component = r.component;
    if (typeof r.type === 'string')
        sf.type = r.type;
    if (typeof r.handler === 'string')
        sf.handler = r.handler;
    if (typeof r.test_hint === 'string')
        sf.test_hint = r.test_hint;
    return sf;
}
/**
 * Project a JSON feature group into the normalized shape.
 *
 * @param {unknown} raw
 * @returns {FeatureGroup | null}
 */
function projectFeatureGroup(raw) {
    if (!raw || typeof raw !== 'object')
        return null;
    const r = /** @type {Record<string, unknown>} */ (raw);
    if (typeof r.id !== 'string' || typeof r.name !== 'string')
        return null;
    const subs = [];
    const rawChildren = Array.isArray(r.sub_features) ? r.sub_features : [];
    for (const c of rawChildren) {
        const sf = projectSubFeature(c);
        if (sf)
            subs.push(sf);
    }
    const group = {
        id: r.id,
        name: r.name,
        wiring: normalizeWiring(r.wired),
        sub_features: subs,
    };
    if (typeof r.component === 'string')
        group.component = r.component;
    if (typeof r.type === 'string')
        group.type = r.type;
    return group;
}
/**
 * Parse one area-JSON file into a FeatureArea.
 *
 * @param {string} file     absolute path to `<area>.json`
 * @returns {{ area: FeatureArea | null, error: string | null }}
 */
function parseAreaFile(file) {
    let raw;
    try {
        raw = readFileSync(file, 'utf-8');
    }
    catch (err) {
        return { area: null, error: `read failed: ${errMsg(err)}` };
    }
    let data;
    try {
        data = JSON.parse(raw);
    }
    catch (err) {
        return { area: null, error: `JSON parse failed: ${errMsg(err)}` };
    }
    if (!data || typeof data !== 'object') {
        return { area: null, error: 'top-level is not an object' };
    }
    const d = /** @type {Record<string, unknown>} */ (data);
    if (typeof d.area !== 'string' || d.area.length === 0) {
        return { area: null, error: 'missing "area" string field' };
    }
    const groups = [];
    const rawFeatures = Array.isArray(d.features) ? d.features : [];
    for (const g of rawFeatures) {
        const group = projectFeatureGroup(g);
        if (group)
            groups.push(group);
    }
    const totalCounts = { wired: 0, stubbed: 0, unwired: 0, total: 0 };
    for (const g of groups) {
        for (const sf of g.sub_features) {
            totalCounts[sf.wiring] += 1;
            totalCounts.total += 1;
        }
        // Empty-sub groups: count the group's own wiring (some area files have
        // this shape — the group itself is the leaf).
        if (g.sub_features.length === 0) {
            totalCounts[g.wiring] += 1;
            totalCounts.total += 1;
        }
    }
    /** @type {FeatureArea} */
    const area = {
        area: d.area,
        features: groups,
        counts: totalCounts,
        source_path: file,
    };
    if (typeof d.route === 'string')
        area.route = d.route;
    if (typeof d.status === 'string')
        area.status = d.status;
    if (typeof d.last_scanned === 'string')
        area.last_scanned = d.last_scanned;
    return { area, error: null };
}
/**
 * Read the features registry from `<projectDir>/.aperant/features/`.
 *
 * @param {string} projectDir
 * @returns {InventoryResult}
 */
export function readFeaturesRegistry(projectDir) {
    const abs = resolve(projectDir);
    const registryDir = join(abs, '.aperant', 'features');
    const empty = {
        areas: 0,
        features: 0,
        sub_features: 0,
        wired: 0,
        stubbed: 0,
        unwired: 0,
    };
    if (!existsSync(registryDir) || !statSync(registryDir).isDirectory()) {
        return {
            status: 'missing',
            registryDir,
            areas: [],
            totals: empty,
            parse_errors: [],
        };
    }
    const entries = readdirSync(registryDir, { withFileTypes: true });
    const files = entries
        .filter((e) => e.isFile() && e.name.endsWith('.json'))
        .map((e) => join(registryDir, e.name))
        .sort();
    if (files.length === 0) {
        return {
            status: 'empty',
            registryDir,
            areas: [],
            totals: empty,
            parse_errors: [],
        };
    }
    const areas = [];
    const parseErrors = [];
    for (const file of files) {
        const { area, error } = parseAreaFile(file);
        if (error)
            parseErrors.push({ file, message: error });
        if (area)
            areas.push(area);
    }
    const totals = {
        areas: areas.length,
        features: 0,
        sub_features: 0,
        wired: 0,
        stubbed: 0,
        unwired: 0,
    };
    for (const a of areas) {
        totals.features += a.features.length;
        totals.sub_features += a.counts.total;
        totals.wired += a.counts.wired;
        totals.stubbed += a.counts.stubbed;
        totals.unwired += a.counts.unwired;
    }
    return {
        status: areas.length > 0 ? 'ok' : 'empty',
        registryDir,
        areas,
        totals,
        parse_errors: parseErrors,
    };
}
/**
 * Best-effort extract the human-readable "last scanned" line from the
 * registry's REGISTRY.md. Returns null when the file is missing or does not
 * contain a matching line. Used by the nav renderer for the timestamp in
 * the generated README.
 *
 * @param {string} projectDir
 * @returns {string | null}
 */
export function readRegistryLastScanned(projectDir) {
    const abs = resolve(projectDir);
    const mdPath = join(abs, '.aperant', 'features', 'REGISTRY.md');
    if (!existsSync(mdPath))
        return null;
    let text;
    try {
        text = readFileSync(mdPath, 'utf-8');
    }
    catch {
        return null;
    }
    const m = text.match(/^Last scanned:\s*([^\n]+)$/m);
    return m ? m[1].trim() : null;
}
/**
 * @param {unknown} err
 * @returns {string}
 */
function errMsg(err) {
    if (err && typeof err === 'object' && 'message' in err) {
        const m = /** @type {{ message: unknown }} */ (err).message;
        return typeof m === 'string' ? m : String(m);
    }
    return String(err);
}
//# sourceMappingURL=features-reader.mjs.map