"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.validateFlowDef = validateFlowDef;
const express_1 = require("express");
const config_1 = require("../config");
const authz_1 = require("../middleware/authz");
const store_1 = require("../flows/store");
const runner_1 = require("../flows/runner");
const cross_lock_1 = require("../tracks/cross-lock");
const types_1 = require("../flows/types");
const logger_1 = require("../logger");
const log = (0, logger_1.modLogger)('flows-route');
const router = (0, express_1.Router)();
// ── Validation ────────────────────────────────────────────────────────────
/** Names must be non-empty, not contain control bytes or backticks (backticks
 *  wrap names in prompt blocks; control bytes can corrupt PTY paste mode). */
const PROMPT_UNSAFE = /[\x00-\x08\x0b\x0c\x0e-\x1f`]/;
/** Extract all `{{var:name}}` and `{{const:name}}` references from a prompt
 *  template. Returned names are trimmed. */
function extractTemplateRefs(tpl) {
    const vars = new Set();
    const consts = new Set();
    const varRe = /\{\{var:([^}]+)\}\}/g;
    const constRe = /\{\{const:([^}]+)\}\}/g;
    let m;
    while ((m = varRe.exec(tpl)) !== null)
        vars.add(m[1].trim());
    while ((m = constRe.exec(tpl)) !== null)
        consts.add(m[1].trim());
    return { vars, consts };
}
function validateFlowDef(obj) {
    if (!obj || typeof obj !== 'object')
        return false;
    const d = obj;
    // Hard schemaVersion gate. v1 (pre-workflow_data) defs are rejected — the
    // refactor is intentionally non-backward-compatible.
    if (d.schemaVersion !== types_1.SCHEMA_VERSION)
        return false;
    if (typeof d.id !== 'string' || !d.id)
        return false;
    if (typeof d.name !== 'string' || !d.name)
        return false;
    if (typeof d.entryNodeId !== 'number')
        return false;
    if (!Array.isArray(d.nodes) || d.nodes.length === 0)
        return false;
    // Constants + variables share a single namespace; names must be unique
    // across both lists (a system-logic branch.variable/constant referencing
    // a name would be ambiguous otherwise).
    const declaredNames = new Set();
    const constantNames = new Set();
    const variableNames = new Set();
    if (d.constants !== undefined) {
        if (!Array.isArray(d.constants))
            return false;
        for (const c of d.constants) {
            if (!c || typeof c !== 'object')
                return false;
            const cr = c;
            if (typeof cr.name !== 'string')
                return false;
            const name = cr.name.trim();
            if (!name)
                return false;
            if (PROMPT_UNSAFE.test(name))
                return false;
            if (declaredNames.has(name))
                return false;
            declaredNames.add(name);
            constantNames.add(name);
            cr.name = name;
            // value: any JSON; must serialize. Validator accepts anything that
            // JSON.stringify can round-trip; circular refs / functions get caught
            // by the runtime writer.
            try {
                JSON.stringify(cr.value);
            }
            catch {
                return false;
            }
            if (cr.description !== undefined && typeof cr.description !== 'string')
                return false;
            if (typeof cr.description === 'string' && PROMPT_UNSAFE.test(cr.description))
                return false;
        }
    }
    if (d.variables !== undefined) {
        if (!Array.isArray(d.variables))
            return false;
        for (const v of d.variables) {
            if (!v || typeof v !== 'object')
                return false;
            const vr = v;
            if (typeof vr.name !== 'string')
                return false;
            const name = vr.name.trim();
            if (!name)
                return false;
            if (PROMPT_UNSAFE.test(name))
                return false;
            if (declaredNames.has(name))
                return false; // collision with constants OR another variable
            declaredNames.add(name);
            variableNames.add(name);
            vr.name = name;
            if (typeof vr.description !== 'string')
                return false;
            if (PROMPT_UNSAFE.test(vr.description))
                return false;
            if (vr.initialValue !== undefined) {
                try {
                    JSON.stringify(vr.initialValue);
                }
                catch {
                    return false;
                }
            }
        }
    }
    // Node validation
    const seenIds = new Set();
    for (const raw of d.nodes) {
        if (!raw || typeof raw !== 'object')
            return false;
        const n = raw;
        if (typeof n.id !== 'number')
            return false;
        if (seenIds.has(n.id))
            return false;
        seenIds.add(n.id);
        if (typeof n.name !== 'string')
            return false;
        const kind = n.kind;
        if (kind === 'user-input') {
            const schema = n.userInputSchema;
            if (!schema || !Array.isArray(schema.fields))
                return false;
            const fieldKeys = new Set();
            for (const f of schema.fields) {
                const fr = f;
                if (typeof fr.key !== 'string')
                    return false;
                const trimmedKey = fr.key.trim();
                if (!trimmedKey)
                    return false;
                if (fieldKeys.has(trimmedKey))
                    return false;
                fieldKeys.add(trimmedKey);
                fr.key = trimmedKey;
                if (typeof fr.label !== 'string')
                    return false;
                if (fr.type !== 'text' && fr.type !== 'textarea')
                    return false;
                // outputVariable / bindVariable / bindConstant: exactly 0 or 1 must
                // be set. Setting two is ambiguous (does the user value or the
                // displayed value win on submit?).
                const out = typeof fr.outputVariable === 'string' && fr.outputVariable.trim().length > 0
                    ? fr.outputVariable.trim() : null;
                const bindV = typeof fr.bindVariable === 'string' && fr.bindVariable.trim().length > 0
                    ? fr.bindVariable.trim() : null;
                const bindC = typeof fr.bindConstant === 'string' && fr.bindConstant.trim().length > 0
                    ? fr.bindConstant.trim() : null;
                const setCount = [out, bindV, bindC].filter((x) => x !== null).length;
                if (setCount > 1)
                    return false;
                if (out !== null && !variableNames.has(out))
                    return false;
                if (bindV !== null && !variableNames.has(bindV))
                    return false;
                if (bindC !== null && !constantNames.has(bindC))
                    return false;
                // Normalize on disk — only one binding key is kept.
                delete fr.outputVariable;
                delete fr.bindVariable;
                delete fr.bindConstant;
                if (out !== null)
                    fr.outputVariable = out;
                else if (bindV !== null)
                    fr.bindVariable = bindV;
                else if (bindC !== null)
                    fr.bindConstant = bindC;
            }
            if (n.next !== null && typeof n.next !== 'number')
                return false;
        }
        else if (kind === 'llm') {
            if (typeof n.promptTemplate !== 'string')
                return false;
            if (typeof n.timeoutSec !== 'number' || !(n.timeoutSec > 0))
                return false;
            if (n.next !== null && typeof n.next !== 'number')
                return false;
            // Validate readVariables / writeVariables / readConstants are declared.
            for (const field of ['readVariables', 'writeVariables']) {
                if (n[field] !== undefined) {
                    if (!Array.isArray(n[field]))
                        return false;
                    const normalized = [];
                    for (const vn of n[field]) {
                        if (typeof vn !== 'string')
                            return false;
                        const trimmed = vn.trim();
                        if (!variableNames.has(trimmed))
                            return false;
                        normalized.push(trimmed);
                    }
                    n[field] = normalized;
                }
            }
            if (n.readConstants !== undefined) {
                if (!Array.isArray(n.readConstants))
                    return false;
                const normalized = [];
                for (const cn of n.readConstants) {
                    if (typeof cn !== 'string')
                        return false;
                    const trimmed = cn.trim();
                    if (!constantNames.has(trimmed))
                        return false;
                    normalized.push(trimmed);
                }
                n.readConstants = normalized;
            }
            // promptTemplate references {{var:X}} / {{const:Y}}: X must be a
            // declared variable, Y a declared constant. Catches typos at save time.
            const refs = extractTemplateRefs(n.promptTemplate);
            for (const v of refs.vars) {
                if (!variableNames.has(v))
                    return false;
            }
            for (const c of refs.consts) {
                if (!constantNames.has(c))
                    return false;
            }
        }
        else if (kind === 'system-logic') {
            if (!Array.isArray(n.branches))
                return false;
            for (const b of n.branches) {
                if (!b || typeof b !== 'object')
                    return false;
                const br = b;
                if (typeof br.goto !== 'number')
                    return false;
                const v = typeof br.variable === 'string' ? br.variable.trim() : '';
                const c = typeof br.constant === 'string' ? br.constant.trim() : '';
                const hasV = v.length > 0;
                const hasC = c.length > 0;
                if (!hasV && !hasC)
                    return false; // need one
                if (hasV && hasC)
                    return false; // not both
                if (hasV) {
                    if (!variableNames.has(v))
                        return false;
                    br.variable = v;
                    delete br.constant;
                }
                else {
                    if (!constantNames.has(c))
                        return false;
                    br.constant = c;
                    delete br.variable;
                }
            }
            if (typeof n.maxRetries !== 'number' || !(n.maxRetries >= 0))
                return false;
            if (n.defaultGoto != null && typeof n.defaultGoto !== 'number')
                return false;
        }
        else {
            return false;
        }
    }
    // entryNodeId must point to an existing node
    if (!seenIds.has(d.entryNodeId))
        return false;
    // All next/defaultGoto/goto must point to existing nodes (or null)
    for (const raw of d.nodes) {
        const n = raw;
        if (n.kind === 'user-input' || n.kind === 'llm') {
            if (n.next !== null && typeof n.next === 'number' && !seenIds.has(n.next))
                return false;
        }
        else if (n.kind === 'system-logic') {
            if (n.defaultGoto != null && typeof n.defaultGoto === 'number' && !seenIds.has(n.defaultGoto))
                return false;
            for (const b of n.branches) {
                if (!seenIds.has(b.goto))
                    return false;
            }
        }
    }
    return true;
}
// ── Definition CRUD ───────────────────────────────────────────────────────
// GET /api/projects/:projectId/flows
router.get('/:projectId/flows', (0, authz_1.requireProjectOwner)('projectId'), (req, res) => {
    const project = (0, config_1.getProject)(req.params.projectId);
    if (!project) {
        res.status(404).json({ error: 'Project not found' });
        return;
    }
    res.json({ files: (0, store_1.listFlowDefs)(project.folderPath) });
});
// GET /api/projects/:projectId/flows/file/:filename
router.get('/:projectId/flows/file/:filename', (0, authz_1.requireProjectOwner)('projectId'), (req, res) => {
    const project = (0, config_1.getProject)(req.params.projectId);
    if (!project) {
        res.status(404).json({ error: 'Project not found' });
        return;
    }
    const safe = (0, store_1.sanitizeFlowFilename)(req.params.filename);
    if (!safe) {
        res.status(400).json({ error: 'invalid filename' });
        return;
    }
    const def = (0, store_1.loadFlowDef)(project.folderPath, safe);
    if (!def) {
        res.status(404).json({ error: 'Flow not found' });
        return;
    }
    res.json(def);
});
// PUT /api/projects/:projectId/flows/file/:filename
router.put('/:projectId/flows/file/:filename', (0, authz_1.requireProjectOwner)('projectId'), (req, res) => {
    const project = (0, config_1.getProject)(req.params.projectId);
    if (!project) {
        res.status(404).json({ error: 'Project not found' });
        return;
    }
    const safe = (0, store_1.sanitizeFlowFilename)(req.params.filename);
    if (!safe) {
        res.status(400).json({ error: 'invalid filename' });
        return;
    }
    if (!validateFlowDef(req.body)) {
        log.warn({ projectId: project.id, filename: safe }, 'flow save rejected (validation)');
        res.status(400).json({ error: 'invalid flow definition' });
        return;
    }
    const ok = (0, store_1.saveFlowDef)(project.folderPath, safe, req.body);
    log.info({ projectId: project.id, filename: safe, nodeCount: req.body.nodes.length, entryNodeId: req.body.entryNodeId }, 'flow saved');
    res.json({ ok });
});
// DELETE /api/projects/:projectId/flows/file/:filename
router.delete('/:projectId/flows/file/:filename', (0, authz_1.requireProjectOwner)('projectId'), (req, res) => {
    const project = (0, config_1.getProject)(req.params.projectId);
    if (!project) {
        res.status(404).json({ error: 'Project not found' });
        return;
    }
    const safe = (0, store_1.sanitizeFlowFilename)(req.params.filename);
    if (!safe) {
        res.status(400).json({ error: 'invalid filename' });
        return;
    }
    const ok = (0, store_1.deleteFlowDef)(project.folderPath, safe);
    log.info({ projectId: project.id, filename: safe, ok }, 'flow deleted');
    res.json({ ok });
});
// ── Runtime ───────────────────────────────────────────────────────────────
// POST /api/projects/:projectId/flows/run  body: { filename, source?: 'project' | 'global' }
router.post('/:projectId/flows/run', (0, authz_1.requireProjectOwner)('projectId'), (req, res) => {
    const project = (0, config_1.getProject)(req.params.projectId);
    if (!project) {
        res.status(404).json({ error: 'Project not found' });
        return;
    }
    const filename = typeof req.body?.filename === 'string' ? req.body.filename : '';
    const safe = (0, store_1.sanitizeFlowFilename)(filename);
    if (!safe) {
        res.status(400).json({ error: 'invalid filename' });
        return;
    }
    const source = req.body?.source === 'global' ? 'global' : 'project';
    let def = null;
    if (source === 'global') {
        const username = req.user?.username;
        if (!username) {
            res.status(401).json({ error: 'auth required' });
            return;
        }
        def = (0, store_1.loadGlobalFlowDef)(username, safe);
    }
    else {
        def = (0, store_1.loadFlowDef)(project.folderPath, safe);
    }
    if (!def) {
        res.status(404).json({ error: 'Flow not found' });
        return;
    }
    // Cross-subsystem lock: refuse to start a flow if a track is currently
    // running on the same project. Both write workflow_data.json (RMW race).
    // Symmetric guard in routes/tracks.ts.
    if ((0, cross_lock_1.isTrackRunning)(project.id)) {
        res.status(409).json({
            error: 'a track is currently running on this project; abort it first',
        });
        return;
    }
    const result = runner_1.flowRunner.start(project.id, project.folderPath, def, safe);
    if (!result.ok) {
        res.status(409).json({ error: result.reason ?? 'cannot start' });
        return;
    }
    log.info({ projectId: project.id, flow: safe, source }, 'flow started');
    res.json({ ok: true, state: result.state });
});
// POST /api/projects/:projectId/flows/abort
router.post('/:projectId/flows/abort', (0, authz_1.requireProjectOwner)('projectId'), (req, res) => {
    const ok = runner_1.flowRunner.abort(req.params.projectId);
    log.info({ projectId: req.params.projectId, ok }, 'flow abort requested');
    res.json({ ok });
});
// POST /api/projects/:projectId/flows/resume
router.post('/:projectId/flows/resume', (0, authz_1.requireProjectOwner)('projectId'), (req, res) => {
    const ok = runner_1.flowRunner.resume(req.params.projectId);
    log.info({ projectId: req.params.projectId, ok }, 'flow resume requested');
    if (!ok) {
        res.status(409).json({ error: 'cannot resume (not paused or awaiting user input)' });
        return;
    }
    res.json({ ok });
});
// POST /api/projects/:projectId/flows/input  body: { data: {key: value, ...} }
router.post('/:projectId/flows/input', (0, authz_1.requireProjectOwner)('projectId'), (req, res) => {
    const data = req.body?.data;
    if (!data || typeof data !== 'object') {
        res.status(400).json({ error: 'data is required' });
        return;
    }
    const cleaned = {};
    for (const [k, v] of Object.entries(data)) {
        cleaned[k] = typeof v === 'string' ? v : String(v);
    }
    const ok = runner_1.flowRunner.submitUserInput(req.params.projectId, cleaned);
    if (!ok) {
        res.status(409).json({ error: 'no flow awaiting input' });
        return;
    }
    log.info({ projectId: req.params.projectId, fieldCount: Object.keys(cleaned).length }, 'flow user-input submission');
    res.json({ ok });
});
// GET /api/projects/:projectId/flows/state
router.get('/:projectId/flows/state', (0, authz_1.requireProjectOwner)('projectId'), (req, res) => {
    const project = (0, config_1.getProject)(req.params.projectId);
    if (!project) {
        res.status(404).json({ error: 'Project not found' });
        return;
    }
    const state = runner_1.flowRunner.getState(project.id) ?? (0, store_1.loadFlowState)(project.folderPath);
    res.json({ running: runner_1.flowRunner.isRunning(project.id), state });
});
exports.default = router;
//# sourceMappingURL=flows.js.map