/**
 * Normalise rule: `crud-into-curved`.
 *
 * Phase 3 of `2026-05-13-NORMALISE-PHASE.md` §5 Rule 2. Lifts Service
 * operations that match the canonical CRUD shape onto their target
 * entity's Controller `cured:` block. This eliminates redundant service
 * shells for plain CRUD wrappers — SpecVerse generates
 * `cured.retrieve/create/update/delete` from a controller's cured
 * declaration, so an explicit `Service.getClub` that just does
 * "look up by id" is duplicating convention.
 *
 * Schema note: CURVED operations live on Controllers, NOT on Models.
 * If the target entity has no Controller declared, the rule synthesises
 * a `<Entity>Controller` (with `model: <Entity>`) in the same component
 * as the Model. The cured op lands there.
 *
 * Before (post-Rule-1 / cluster-module-functions):
 *
 *   components:
 *     App:
 *       models:
 *         Club:
 *           attributes: [...]
 *       services:
 *         ClubsService:
 *           operations:
 *             getClub:
 *               steps: [Look up Club by id, Return Club or null]
 *               requires: [id is provided]
 *             getClubPositions:                 # NOT lifted (filtered query)
 *               steps: [...]
 *
 * After:
 *
 *   components:
 *     App:
 *       models:
 *         Club:
 *           attributes: [...]
 *       controllers:
 *         ClubController:                       # synthesised by this rule
 *           model: Club
 *           cured:
 *             retrieve:
 *               steps: [Look up Club by id, Return Club or null]
 *               requires: [id is provided]
 *       services:
 *         ClubsService:
 *           operations:
 *             getClubPositions: { ... }        # remains
 *
 * If the service becomes empty after lifting, it is dropped entirely.
 *
 * Conservative matching:
 *   - Op name must be EXACTLY `<verb><EntityName>` — no trailing suffix
 *     (`getRound` ✓, `getRoundsForUser` ✗, `getRoundStatus` ✗)
 *   - Target Entity name must resolve UNIQUELY to ONE model across all
 *     components (the cured block goes to whichever component the model
 *     lives in — cross-component lifts are fine since the cured: lives
 *     ON the model, not at the service site)
 *   - Skip when the entity name is declared in multiple components (rare,
 *     but ambiguous — caller can rename the duplicate)
 *   - Target model's `cured.<curvedOp>` must NOT already be declared
 *     (don't overwrite spec-author intent)
 *   - Verb patterns recognised:
 *       get / retrieve / find → retrieve
 *       create / add          → create   (Phase 3: only `create` to stay safe)
 *       update                → update
 *       delete / remove       → delete   (Phase 3: only `delete` to stay safe)
 *
 * NOT in Phase 3 scope:
 *   - retrieve_many (list / getAll / search — naming varies too widely)
 *   - Evolve transitions (would need lifecycle state-machine signal)
 *   - Cross-component lifts (target model in a different component)
 *   - Multi-arg create variants (createClubWithPositions)
 */
/** Verb → CURVED op mapping. Order matters for prefix matching (longer first). */
const VERB_MAP = [
    { verb: 'retrieve', curved: 'retrieve' },
    { verb: 'create', curved: 'create' },
    { verb: 'update', curved: 'update' },
    { verb: 'delete', curved: 'delete' },
    { verb: 'find', curved: 'retrieve' },
    { verb: 'get', curved: 'retrieve' },
];
/**
 * Parse an operation name into (verb, entityName) if it matches the
 * exact `<verb><PascalCaseEntity>` shape. Returns null otherwise.
 *
 * The entity portion must be PascalCase, contain no further uppercase
 * boundaries after the leading letter (i.e. single PascalCase word),
 * AND must end at the end of the string (no trailing qualifier like
 * `getRoundStatus` or `getClubsForUser`).
 */
function parseCrudOpName(opName) {
    for (const { verb, curved } of VERB_MAP) {
        if (!opName.startsWith(verb))
            continue;
        const rest = opName.slice(verb.length);
        if (rest.length === 0)
            continue;
        // First character must be uppercase (start of entity name).
        if (!(rest[0] >= 'A' && rest[0] <= 'Z'))
            continue;
        // Rest must be a SINGLE PascalCase word — no further capital letters
        // after the first character. This rules out compound names like
        // `getClubPositions` (Positions is a 2nd PascalCase token →
        // domain-filtered query, NOT plain retrieve).
        let hasSecondCapital = false;
        for (let i = 1; i < rest.length; i++) {
            const c = rest[i];
            if (c >= 'A' && c <= 'Z') {
                hasSecondCapital = true;
                break;
            }
        }
        if (hasSecondCapital)
            continue;
        return { verb, curved, entity: rest };
    }
    return null;
}
/**
 * Build a model-name → component-name index across all components. Used
 * by the cross-component target lookup. When an entity name is declared
 * in multiple components, it's recorded as ambiguous (the rule then
 * skips matches against that name).
 */
function buildModelIndex(spec) {
    const out = new Map();
    const components = spec.components;
    if (!components)
        return out;
    for (const [componentName, componentBody] of Object.entries(components)) {
        const models = componentBody?.models;
        if (!models)
            continue;
        for (const modelName of Object.keys(models)) {
            const existing = out.get(modelName);
            if (existing === undefined)
                out.set(modelName, componentName);
            else if (existing !== componentName)
                out.set(modelName, 'ambiguous');
        }
    }
    return out;
}
function detectMatchesInComponent(spec, modelIndex, componentName, componentBody) {
    const out = [];
    const services = componentBody.services;
    if (!services)
        return out;
    for (const [serviceName, serviceBody] of Object.entries(services)) {
        if (!serviceBody || typeof serviceBody !== 'object')
            continue;
        const ops = serviceBody.operations;
        if (!ops || typeof ops !== 'object')
            continue;
        for (const opName of Object.keys(ops)) {
            const parsed = parseCrudOpName(opName);
            if (!parsed)
                continue;
            const targetComponent = modelIndex.get(parsed.entity);
            if (!targetComponent || targetComponent === 'ambiguous')
                continue;
            // Look up the target controller's cured block (if a controller
            // already exists for this model). Skip if the op is already
            // declared. Controllers may be missing — that's fine; the
            // transform will synthesise one.
            const components = spec.components;
            const targetComponentBody = components[targetComponent];
            const existingController = findControllerForModel(targetComponentBody, parsed.entity);
            if (existingController) {
                const existingCured = existingController.cured;
                if (existingCured && parsed.curved in existingCured)
                    continue;
            }
            out.push({
                sourceComponentName: componentName,
                targetComponentName: targetComponent,
                modelName: parsed.entity,
                serviceName,
                operationName: opName,
                curvedOp: parsed.curved,
            });
        }
    }
    return out;
}
/**
 * Find the controller in `componentBody` whose `model:` field references
 * the given entity name. Returns the controller body, or null if none.
 * The controller's KEY in the controllers object is the controller name
 * (e.g. ClubController); the `model:` field declares which model it
 * operates on.
 */
function findControllerForModel(componentBody, modelName) {
    const controllers = componentBody.controllers;
    if (!controllers || typeof controllers !== 'object')
        return null;
    for (const ctrlBody of Object.values(controllers)) {
        if (!ctrlBody || typeof ctrlBody !== 'object')
            continue;
        if (ctrlBody.model === modelName) {
            return ctrlBody;
        }
    }
    return null;
}
function applyCrudMatch(spec, data) {
    const components = spec.components;
    const sourceComponent = components[data.sourceComponentName];
    const targetComponent = components[data.targetComponentName];
    const services = sourceComponent.services;
    const serviceBody = services[data.serviceName];
    const ops = serviceBody.operations;
    const opBody = ops[data.operationName];
    // Locate the target controller (synthesise one if absent).
    let targetController = findControllerForModel(targetComponent, data.modelName);
    let targetControllerName = null;
    if (!targetController) {
        targetControllerName = `${data.modelName}Controller`;
        targetController = { model: data.modelName };
        // Insert into the component's controllers map. Create the map if
        // it doesn't exist (preserving the standard position in the
        // component — models then controllers then services).
        const existingControllers = targetComponent.controllers ?? {};
        existingControllers[targetControllerName] = targetController;
        targetComponent.controllers = existingControllers;
    }
    else {
        // Find the key of the existing controller (we have its body, need its name for description).
        const allControllers = targetComponent.controllers;
        for (const [k, v] of Object.entries(allControllers)) {
            if (v === targetController) {
                targetControllerName = k;
                break;
            }
        }
    }
    // Insert under controller.cured.<curvedOp>. Preserve standard order.
    const existingCured = targetController.cured ?? {};
    const newCured = {};
    const STANDARD_ORDER = ['create', 'update', 'retrieve', 'retrieve_many', 'validate', 'evolve', 'delete'];
    const allKeys = new Set([...Object.keys(existingCured), data.curvedOp]);
    for (const k of STANDARD_ORDER) {
        if (allKeys.has(k)) {
            newCured[k] = k === data.curvedOp ? opBody : existingCured[k];
        }
    }
    for (const [k, v] of Object.entries(existingCured)) {
        if (!STANDARD_ORDER.includes(k))
            newCured[k] = v;
    }
    targetController.cured = newCured;
    // Remove operation from service.
    delete ops[data.operationName];
    // If the service has no remaining operations, drop the service. If
    // doing so leaves an empty services map, drop that too.
    if (Object.keys(ops).length === 0) {
        delete services[data.serviceName];
        if (Object.keys(services).length === 0) {
            delete sourceComponent.services;
        }
    }
    return spec;
}
export const CRUD_INTO_CURVED_RULE = {
    id: 'crud-into-curved',
    summary: 'Lift Service operations matching <verb><Entity> exactly (where Entity is a model in the same component) to the model\'s cured: block. Drop service if empty.',
    ordering: 10,
    enabledByDefault: true,
    appliesWhen: (ctx) => ctx.source !== 'hand-authored',
    detect: (spec) => {
        const matches = [];
        const components = spec.components;
        if (!components || typeof components !== 'object')
            return matches;
        const modelIndex = buildModelIndex(spec);
        for (const [componentName, componentBody] of Object.entries(components)) {
            if (!componentBody || typeof componentBody !== 'object')
                continue;
            const found = detectMatchesInComponent(spec, modelIndex, componentName, componentBody);
            for (const data of found) {
                const crossComp = data.sourceComponentName !== data.targetComponentName;
                matches.push({
                    description: `${data.sourceComponentName}.${data.serviceName}.${data.operationName} → ${data.targetComponentName}.${data.modelName}.cured.${data.curvedOp}${crossComp ? ' (cross-component)' : ''}`,
                    data,
                });
            }
        }
        return matches;
    },
    transform: (spec, match) => {
        return applyCrudMatch(spec, match.data);
    },
};
export const __internal = {
    parseCrudOpName,
    buildModelIndex,
    detectMatchesInComponent,
};
//# sourceMappingURL=crud-into-curved.js.map