// Repo + process helpers for the social-autoposter MCP wrapper.
// This MCP is a THIN client: it shells out to the existing pipeline scripts in
// the social-autoposter repo and never reimplements pipeline logic.
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs";
import { resolvePython, resolveRepoDir } from "./runtime.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// The pipeline repo the wrapper shells out to. Resolved DYNAMICALLY per call (a
// getter, not a load-time const) because a bare .mcpb double-click materializes
// the repo AFTER the server boots; the very next shell-out must pick it up
// without a restart. resolveRepoDir() prefers a real S4L_REPO_DIR clone, then
// the materialized repo recorded in runtime.json, then a dev fallback.
export function repoDir() {
    return resolveRepoDir();
}
// Python used by the pipeline. Resolved DYNAMICALLY per call (not a load-time
// const) because the owned uv runtime can be provisioned AFTER the server boots
// — the first-run installer writes runtime.json and the very next runPython
// call must pick up the owned interpreter without a server restart.
// resolvePython() prefers the owned runtime, then S4L_PYTHON, then python3.
// The locked pipeline script (run-twitter-cycle.sh) writes the draft plan to a
// HARDCODED /tmp path (`PLAN_FILE="/tmp/twitter_cycle_plan_<batch>.json"`), and the
// `DRAFT_ONLY_PLAN=` marker the wrapper parses is rooted at /tmp too. We MUST read
// and write plans from the same directory. os.tmpdir() is NOT /tmp on macOS (it
// honors $TMPDIR, e.g. /var/folders/.../T, or /tmp/claude-501 inside Claude Code),
// which silently stranded every draft and made draft_cycle always report
// "No drafts in batch ...". Default to /tmp to match the script; allow an explicit
// override for non-standard installs.
export const TMP_DIR = process.env.S4L_TMP_DIR || "/tmp";
let lineSink = null;
export function setLineSink(fn) {
    lineSink = fn;
}
// Derive a short, stable context label for a spawned command so log lines can
// be grouped by which script produced them (e.g. "scripts/seed_search_topics.py",
// "skill/run-twitter-cycle.sh"). Best-effort; never throws.
function deriveContext(cmd, args) {
    try {
        const base = cmd.split("/").pop() || cmd;
        if (/python/i.test(base) || base === "bash" || base === "sh" || base === "node") {
            const first = args.find((a) => !a.startsWith("-"));
            if (first)
                return first;
        }
        return [base, args[0] ?? ""].join(" ").trim();
    }
    catch {
        return cmd;
    }
}
// Spawn a process inside the repo, inheriting the repo env (API base + keys
// come from the install's environment / .env loaded by the scripts themselves).
//
// `onLine` (optional) fires once per COMPLETE line as the child emits output,
// so a long-running script (e.g. run-twitter-cycle.sh, which can churn for
// minutes) can be followed live instead of going dark until it exits. The full
// buffered stdout/stderr are still returned unchanged, so existing callers are
// unaffected. A throwing sink never breaks the run.
export function run(cmd, args, opts = {}) {
    return new Promise((resolve) => {
        const child = spawn(cmd, args, {
            cwd: opts.cwd || repoDir(),
            env: { ...process.env, ...(opts.env || {}) },
        });
        try {
            opts.onSpawn?.(child);
        }
        catch {
            /* a spawn observer must never break the run */
        }
        let stdout = "";
        let stderr = "";
        // Per-stream partial-line buffers so onLine fires on whole lines only,
        // regardless of how the OS chunks the pipe reads.
        let outBuf = "";
        let errBuf = "";
        const pump = (chunk, which, buf) => {
            if (!opts.onLine)
                return buf;
            buf += chunk;
            let nl;
            while ((nl = buf.indexOf("\n")) !== -1) {
                const line = buf.slice(0, nl);
                buf = buf.slice(nl + 1);
                try {
                    opts.onLine(line, which);
                }
                catch {
                    /* a progress sink must never break the wrapped command */
                }
            }
            return buf;
        };
        // Parallel whole-line splitter that tees to the telemetry sink (if any),
        // kept separate from the onLine pump so neither path can affect the other.
        const logCtx = opts.logContext || deriveContext(cmd, args);
        let sinkOutBuf = "";
        let sinkErrBuf = "";
        const sinkPump = (chunk, which, buf) => {
            const sink = opts.noTee ? null : lineSink;
            if (!sink)
                return buf;
            buf += chunk;
            let nl;
            while ((nl = buf.indexOf("\n")) !== -1) {
                const line = buf.slice(0, nl);
                buf = buf.slice(nl + 1);
                try {
                    sink(line, which, logCtx);
                }
                catch {
                    /* the telemetry sink must never break the wrapped command */
                }
            }
            return buf;
        };
        let timer;
        if (opts.timeoutMs) {
            timer = setTimeout(() => {
                child.kill("SIGTERM");
            }, opts.timeoutMs);
        }
        child.stdout.on("data", (d) => {
            const s = d.toString();
            stdout += s;
            outBuf = pump(s, "stdout", outBuf);
            sinkOutBuf = sinkPump(s, "stdout", sinkOutBuf);
        });
        child.stderr.on("data", (d) => {
            const s = d.toString();
            stderr += s;
            errBuf = pump(s, "stderr", errBuf);
            sinkErrBuf = sinkPump(s, "stderr", sinkErrBuf);
        });
        child.on("close", (code) => {
            if (timer)
                clearTimeout(timer);
            // Flush any trailing partial line (output with no terminating newline).
            if (opts.onLine) {
                if (outBuf)
                    try {
                        opts.onLine(outBuf, "stdout");
                    }
                    catch {
                        /* ignore */
                    }
                if (errBuf)
                    try {
                        opts.onLine(errBuf, "stderr");
                    }
                    catch {
                        /* ignore */
                    }
            }
            const sink = opts.noTee ? null : lineSink;
            if (sink) {
                if (sinkOutBuf)
                    try {
                        sink(sinkOutBuf, "stdout", logCtx);
                    }
                    catch {
                        /* ignore */
                    }
                if (sinkErrBuf)
                    try {
                        sink(sinkErrBuf, "stderr", logCtx);
                    }
                    catch {
                        /* ignore */
                    }
            }
            resolve({ code: code ?? -1, stdout, stderr });
        });
        child.on("error", (err) => {
            if (timer)
                clearTimeout(timer);
            resolve({ code: -1, stdout, stderr: stderr + String(err) });
        });
    });
}
export function runPython(scriptRelPath, args, opts = {}) {
    return run(resolvePython(), [scriptRelPath, ...args], opts);
}
export function planPath(batchId) {
    return path.join(TMP_DIR, `twitter_cycle_plan_${batchId}.json`);
}
export function readPlan(batchId) {
    const p = planPath(batchId);
    if (!fs.existsSync(p))
        return null;
    try {
        return JSON.parse(fs.readFileSync(p, "utf-8"));
    }
    catch {
        return null;
    }
}
export function writePlan(batchId, plan) {
    fs.writeFileSync(planPath(batchId), JSON.stringify(plan, null, 2), "utf-8");
}
// Find the newest plan file when no batch id is supplied.
export function latestBatchId() {
    let files;
    try {
        files = fs.readdirSync(TMP_DIR);
    }
    catch {
        return null;
    }
    const matches = files
        .map((f) => /^twitter_cycle_plan_(.+)\.json$/.exec(f))
        .filter((m) => !!m && !m[1].endsWith("_approved"))
        .map((m) => ({
        batchId: m[1],
        mtime: fs.statSync(path.join(TMP_DIR, m[0])).mtimeMs,
    }))
        .sort((a, b) => b.mtime - a.mtime);
    return matches.length ? matches[0].batchId : null;
}
