/**
 * docs/structure-planner.mjs — tier-aware output-tree planner (C49).
 *
 * Given three inputs — the product-context sidecar (derived from PROJECT.md),
 * the personas envelope (from C48), and the feature inventory (from
 * features-reader.mjs) — this module produces the planned output file tree
 * for `docs/product/`.
 *
 * Locked decisions this module enforces DETERMINISTICALLY (no LLM):
 *
 *   D-02 — Persona-first outer layer, feature-area inner layer.
 *          `docs/product/{persona-id}/{area-id}/index.md`.
 *          - Primary personas: full per-area tree.
 *          - Secondary personas: shared (non-variant) tree — same content
 *            shape as primary, but flagged `variant: false`.
 *          - Tertiary personas: skeleton-only — landing page + area
 *            placeholders, no per-feature pages.
 *
 *   D-03 — Wired features → full docs. Stubbed features → "Coming soon"
 *          stubs (primary-persona only; tertiary stubs omitted). Unwired
 *          features are omitted entirely.
 *
 *   D-04 — Variant-vs-shared decision PER FEATURE. This planner does NOT
 *          make the decision itself (that's the LLM's rubric call inside
 *          the apt-docs-author agent). We emit a `variant_decision_input`
 *          structure for each feature — the raw signal the LLM consumes.
 *          The planned output tree reserves file paths for BOTH variant
 *          and shared shapes so the agent can pick either at write-time.
 *
 * The planner is PURE — inputs in, planned tree out, no I/O. All writes
 * happen in the CLI orchestrator (create-docs.mjs), which consumes this
 * plan + invokes the agent loop.
 */
/**
 * @typedef {import('./features-reader.mjs').FeatureArea} FeatureArea
 * @typedef {import('./features-reader.mjs').FeatureGroup} FeatureGroup
 */
/**
 * @typedef {'primary' | 'secondary' | 'tertiary'} PersonaTier
 */
/**
 * @typedef {Object} PlannedPersona
 * @property {string} id
 * @property {string} name
 * @property {PersonaTier} tier
 * @property {string} landing_path        relative path: `{persona}/README.md`
 */
/**
 * @typedef {Object} PlannedFeatureDoc
 * @property {string} area
 * @property {string} feature_id          group id when feature-as-group; area when area-level leaf
 * @property {string} feature_name
 * @property {'wired' | 'stubbed'} wiring
 * @property {boolean} is_stub            D-03 "Coming soon" marker
 * @property {string} shared_path         relative path when rendered as shared: `_shared/{area}/{feature}.md`
 * @property {Record<string, string>} variant_paths   persona-id → relative path for variant rendering
 * @property {string[]} included_personas persona ids this doc belongs to (primary+secondary wired; primary-only stub)
 * @property {string[]} tertiary_personas persona ids that reference this doc via their skeleton (no dedicated page)
 */
/**
 * @typedef {Object} PlannedArea
 * @property {string} area                    area id (e.g. "terminals")
 * @property {string} [route]
 * @property {number} feature_count_wired
 * @property {number} feature_count_stubbed
 * @property {number} feature_count_unwired_omitted
 * @property {PlannedFeatureDoc[]} features
 */
/**
 * @typedef {Object} StructurePlan
 * @property {string} output_dir_relative         always `docs/product` unless overridden
 * @property {PlannedPersona[]} personas
 * @property {PlannedArea[]} areas
 * @property {Array<{ persona_id: string, area: string, path: string }>} tertiary_skeletons
 * @property {Array<{ persona_id: string, path: string }>} persona_landings
 * @property {string} nav_index_path              `docs/product/README.md`
 * @property {{ areas: number, features_documented: number, stubs_included: number, features_omitted_unwired: number }} totals
 */
/**
 * Parse a `ui_json` string that the personas CLI wraps around the
 * structured persona data. Returns null on any parse failure. The CLI
 * emits personas with `ui_json: '{"name":"Maya","tier":"primary",...}'`
 * — a stringified JSON blob — so we cannot read it positionally.
 *
 * @param {unknown} raw
 * @returns {Record<string, unknown> | null}
 */
function safeParseUiJson(raw) {
    if (!raw)
        return null;
    if (typeof raw === 'object')
        return /** @type {Record<string, unknown>} */ (raw);
    if (typeof raw !== 'string')
        return null;
    try {
        const parsed = JSON.parse(raw);
        if (parsed && typeof parsed === 'object') {
            return /** @type {Record<string, unknown>} */ (parsed);
        }
    }
    catch {
        /* fall through */
    }
    return null;
}
/**
 * Normalize a raw Persona object (from `.aperant/personas.json`) into the
 * `{ id, name, tier }` shape this planner needs. We accept several shapes
 * defensively because the persona envelope is written by both the framework
 * CLI and (eventually) the cloud app wizard.
 *
 * @param {unknown} raw
 * @param {number} index   Fallback slot used to generate stable ids when the
 *                         persona lacks one.
 * @returns {{ id: string, name: string, tier: PersonaTier } | null}
 */
function normalizePersona(raw, index) {
    if (!raw || typeof raw !== 'object')
        return null;
    const p = /** @type {Record<string, unknown>} */ (raw);
    const ui = safeParseUiJson(p.ui_json) ?? {};
    const rawName = (typeof ui.name === 'string' && ui.name) || (typeof p.name === 'string' && p.name) || null;
    if (!rawName)
        return null;
    const rawTier = (typeof ui.tier === 'string' && ui.tier) || (typeof p.tier === 'string' && p.tier) || null;
    const tier = rawTier === 'primary' || rawTier === 'secondary' || rawTier === 'tertiary'
        ? rawTier
        : 'secondary';
    const id = (typeof p.id === 'string' && p.id) ||
        (typeof ui.id === 'string' && ui.id) ||
        slugify(rawName) ||
        `persona-${index + 1}`;
    return { id, name: rawName, tier };
}
/**
 * Slugify a human name into a kebab-case id. Used as a deterministic fallback
 * when a persona lacks an explicit id field.
 *
 * @param {string} name
 * @returns {string}
 */
export function slugify(name) {
    return String(name)
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '');
}
/**
 * Compute an area-level wiring projection: only the groups we will actually
 * document in this persona's tree. Keeps the output plan slim.
 *
 * @param {FeatureArea} area
 * @returns {FeatureGroup[]}
 */
function groupsWorthDocumenting(area) {
    const out = [];
    for (const g of area.features) {
        if (g.wiring === 'wired' || g.wiring === 'stubbed')
            out.push(g);
    }
    return out;
}
/**
 * Plan the output tree.
 *
 * @param {Object} inputs
 * @param {Array<unknown>} inputs.personas
 * @param {FeatureArea[]} inputs.areas
 * @param {string} [inputs.outputDirRelative]
 * @returns {StructurePlan}
 */
export function planStructure({ personas, areas, outputDirRelative = 'docs/product' }) {
    const personaRoster = [];
    for (let i = 0; i < personas.length; i++) {
        const norm = normalizePersona(personas[i], i);
        if (norm)
            personaRoster.push(norm);
    }
    const primaryIds = personaRoster.filter((p) => p.tier === 'primary').map((p) => p.id);
    const secondaryIds = personaRoster.filter((p) => p.tier === 'secondary').map((p) => p.id);
    const tertiaryIds = personaRoster.filter((p) => p.tier === 'tertiary').map((p) => p.id);
    const plannedPersonas = personaRoster.map((p) => ({
        id: p.id,
        name: p.name,
        tier: p.tier,
        landing_path: `${p.id}/README.md`,
    }));
    const personaLandings = plannedPersonas.map((p) => ({
        persona_id: p.id,
        path: p.landing_path,
    }));
    const plannedAreas = [];
    const tertiarySkeletons = [];
    let featuresDocumented = 0;
    let stubsIncluded = 0;
    let featuresOmittedUnwired = 0;
    for (const area of areas) {
        const groups = groupsWorthDocumenting(area);
        let wiredGroupCount = 0;
        let stubbedGroupCount = 0;
        let unwiredOmitted = 0;
        for (const g of area.features) {
            if (g.wiring === 'unwired')
                unwiredOmitted += 1;
        }
        featuresOmittedUnwired += unwiredOmitted;
        const plannedFeatures = [];
        for (const group of groups) {
            const isStub = group.wiring === 'stubbed';
            const shared_path = `_shared/${area.area}/${group.id}.md`;
            const variantPaths = /** @type {Record<string, string>} */ ({});
            for (const pid of primaryIds) {
                variantPaths[pid] = `${pid}/${area.area}/${group.id}.md`;
            }
            for (const pid of secondaryIds) {
                variantPaths[pid] = `${pid}/${area.area}/${group.id}.md`;
            }
            // D-03: stubbed features get included for primary personas only.
            // Wired features get included for primary + secondary.
            const includedPersonas = isStub ? primaryIds.slice() : primaryIds.concat(secondaryIds);
            plannedFeatures.push({
                area: area.area,
                feature_id: group.id,
                feature_name: group.name,
                wiring: isStub ? 'stubbed' : 'wired',
                is_stub: isStub,
                shared_path,
                variant_paths: variantPaths,
                included_personas: includedPersonas,
                tertiary_personas: tertiaryIds.slice(),
            });
            if (isStub) {
                stubbedGroupCount += 1;
                stubsIncluded += 1;
            }
            else {
                wiredGroupCount += 1;
                featuresDocumented += 1;
            }
        }
        plannedAreas.push({
            area: area.area,
            route: area.route,
            feature_count_wired: wiredGroupCount,
            feature_count_stubbed: stubbedGroupCount,
            feature_count_unwired_omitted: unwiredOmitted,
            features: plannedFeatures,
        });
        // D-02: tertiary personas get skeleton-only references — one
        // placeholder `index.md` per area so the user can see the area
        // exists without the full per-feature tree.
        if (plannedFeatures.length > 0) {
            for (const pid of tertiaryIds) {
                tertiarySkeletons.push({
                    persona_id: pid,
                    area: area.area,
                    path: `${pid}/${area.area}/index.md`,
                });
            }
        }
    }
    return {
        output_dir_relative: outputDirRelative,
        personas: plannedPersonas,
        areas: plannedAreas,
        tertiary_skeletons: tertiarySkeletons,
        persona_landings: personaLandings,
        nav_index_path: `${outputDirRelative}/README.md`,
        totals: {
            areas: plannedAreas.length,
            features_documented: featuresDocumented,
            stubs_included: stubsIncluded,
            features_omitted_unwired: featuresOmittedUnwired,
        },
    };
}
/**
 * Compact marker that the LLM agent will consume to make the D-04
 * variant-vs-shared decision per feature. Pure data — we do NOT call the
 * LLM here; the rubric prompt composition lives in the SKILL.md body.
 *
 * @param {PlannedFeatureDoc} feature
 * @param {PlannedPersona[]} personas
 * @returns {{ feature_id: string, candidate_personas: PlannedPersona[], shared_path: string, variant_paths: Record<string, string> }}
 */
export function buildVariantDecisionInput(feature, personas) {
    const candidates = personas.filter((p) => feature.included_personas.includes(p.id) && (p.tier === 'primary' || p.tier === 'secondary'));
    return {
        feature_id: feature.feature_id,
        candidate_personas: candidates,
        shared_path: feature.shared_path,
        variant_paths: feature.variant_paths,
    };
}
//# sourceMappingURL=structure-planner.mjs.map