/**
 * harness.ts — phase harness for the INKBOARD long-horizon trial (pi edition).
 * Same contract as wb_agent5.py: the workspace lives on the model host (spark) and every file/build tool runs there
 * over SSH (pattern from pi's examples/extensions/ssh.ts); the loop, prompts and skills run here in the container.
 *   read/write/edit  : pi's built-in tools with remote operations (+ SCOPE fence, write cap, read budget)
 *   list_files, append_file, revert_file, cargo, trunk_build, verify : custom tools over SSH
 *   budget nudges     : tool_result notes at 70 % of steps/wall, tool calls blocked at 100 %
 *   metrics           : $HARNESS_OUT/metrics.json (+ requests.log with payload sizes, system_prompt.txt, final.md)
 * Env: HARNESS_REMOTE (ssh host), HARNESS_CWD (remote workspace), HARNESS_PHASE, HARNESS_SCOPE (comma globs),
 *      HARNESS_WRITE_CAP, HARNESS_READ_BUDGET, HARNESS_MAX_STEPS, HARNESS_WALL_S, HARNESS_OUT, HARNESS_VERIFY (remote path)
 */
import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync, appendFileSync } from "node:fs";
import { readFile as fsRead, access as fsAccess } from "node:fs/promises";
import { Type } from "typebox";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
	createEditTool, createReadTool, createWriteTool,
	type EditOperations, type ReadOperations, type WriteOperations,
} from "@earendil-works/pi-coding-agent";

const REMOTE = process.env.HARNESS_REMOTE ?? "spark";
const RCWD = process.env.HARNESS_CWD ?? "/home/zerc/wbbench/pi_work";
const PHASE = process.env.HARNESS_PHASE ?? "p";
const SCOPE = (process.env.HARNESS_SCOPE ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const WRITE_CAP = Number(process.env.HARNESS_WRITE_CAP ?? 16000);
const READ_BUDGET = Number(process.env.HARNESS_READ_BUDGET ?? 8);
const POST_BUDGET_LINES = 60;
const MAX_STEPS = Number(process.env.HARNESS_MAX_STEPS ?? 60);
const WALL_S = Number(process.env.HARNESS_WALL_S ?? 3600);
const OUT = process.env.HARNESS_OUT ?? "/out/" + PHASE;
const VERIFY = process.env.HARNESS_VERIFY ?? "/home/zerc/wbbench/v5/verify_ws.py";
const MARKER = "ROUND COMPLETE";
const RENV = "export PATH=$HOME/.cargo/bin:/usr/local/cuda/bin:$PATH CARGO_TERM_COLOR=never DATABASE_URL=postgres://postgres:wb@localhost:5433/wb; ";
const T0 = Date.now();
const localCwd = process.cwd();
const toRemote = (p: string) => (p.startsWith(localCwd) ? p.replace(localCwd, RCWD) : p);
const rel = (p: string) => { const r = toRemote(p); return r.startsWith(RCWD + "/") ? r.slice(RCWD.length + 1) : r === RCWD ? "." : r.replace(/^\.\//, ""); };
const stats: Record<string, any> = { phase: PHASE, scope: SCOPE, steps: 0, reads: 0, writes: 0, edits: 0, appends: 0, reverts: 0,
	cargo_runs: 0, cargo_failures: 0, trunk_builds: 0, verify_runs: 0, scope_refusals: 0, oversized_writes: 0, blocked_calls: 0,
	stall_warnings: 0, requests: 0, request_chars_max: 0, tool_errors: 0 };
mkdirSync(OUT, { recursive: true });
const log = (f: string, s: string) => { try { appendFileSync(OUT + "/" + f, s); } catch {} };
const saveStats = () => { stats.elapsed_s = Math.round((Date.now() - T0) / 1000); try { writeFileSync(OUT + "/metrics.json", JSON.stringify(stats, null, 1)); } catch {} };

function ssh(cmd: string, timeoutS = 120): Promise<{ code: number | null; out: string }> {
	return new Promise((resolve) => {
		const child = spawn("ssh", ["-o", "BatchMode=yes", REMOTE, cmd], { stdio: ["ignore", "pipe", "pipe"] });
		const chunks: Buffer[] = [];
		child.stdout.on("data", (d) => chunks.push(d)); child.stderr.on("data", (d) => chunks.push(d));
		const timer = setTimeout(() => child.kill(), timeoutS * 1000);
		child.on("error", (e) => { clearTimeout(timer); resolve({ code: -1, out: String(e) }); });
		child.on("close", (code) => { clearTimeout(timer); resolve({ code, out: Buffer.concat(chunks).toString() }); });
	});
}
function sshBuf(cmd: string): Promise<Buffer> {
	return new Promise((resolve, reject) => {
		const child = spawn("ssh", ["-o", "BatchMode=yes", REMOTE, cmd], { stdio: ["ignore", "pipe", "pipe"] });
		const chunks: Buffer[] = []; const err: Buffer[] = [];
		child.stdout.on("data", (d) => chunks.push(d)); child.stderr.on("data", (d) => err.push(d));
		child.on("error", reject);
		child.on("close", (code) => (code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(`ssh failed (${code}): ${Buffer.concat(err).toString().slice(0, 300)}`))));
	});
}
const q = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
const clip = (s: string, head = 1500, tail = 6500) => (s.length <= head + tail ? s : s.slice(0, head) + `\n...[${s.length - head - tail} chars omitted]...\n` + s.slice(-tail));

// ---- SCOPE fence (v5 semantics: glob on the workspace-relative path; override_reason lets the model step out deliberately)
function globToRe(g: string) { return new RegExp("^" + g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*") + "$"); }
const scopeRes = SCOPE.map(globToRe);
function inScope(p: string) { const r = rel(p); return scopeRes.length === 0 || scopeRes.some((re) => re.test(r)) || r.startsWith("docs/"); }
function scopeError(p: string) { stats.scope_refusals++; return `ERROR: ${rel(p)} is outside this phase's SCOPE (${SCOPE.join(", ")}). Files outside the scope are earlier phases' work; a failing test there is not your task. If you are sure, call again with override_reason.`; }

// ---- remote operations for pi's own read/write/edit (paths outside the workspace, e.g. skills, stay local)
const readOps: ReadOperations = {
	readFile: (p) => (p.startsWith(localCwd) ? sshBuf(`cat ${q(toRemote(p))}`) : fsRead(p)),
	access: (p) => (p.startsWith(localCwd) ? sshBuf(`test -r ${q(toRemote(p))}`).then(() => {}) : fsAccess(p)),
	detectImageMimeType: async () => null,
};
const writeOps: WriteOperations = {
	writeFile: async (p, content) => { const b64 = Buffer.from(content).toString("base64"); await sshBuf(`mkdir -p "$(dirname ${q(toRemote(p))})" && echo ${b64} | base64 -d > ${q(toRemote(p))}`); },
	mkdir: (d) => sshBuf(`mkdir -p ${q(toRemote(d))}`).then(() => {}),
};
const editOps: EditOperations = { readFile: readOps.readFile, access: readOps.access, writeFile: writeOps.writeFile };

// ---- stall detection (same first error coming back)
let lastErr = { sig: "", n: 0 };
function stallNote(out: string) {
	const m = out.match(/^(error(\[E\d+\])?: .*)$/m); if (!m) { lastErr = { sig: "", n: 0 }; return ""; }
	const sig = m[1].slice(0, 120);
	if (sig === lastErr.sig) lastErr.n++; else lastErr = { sig, n: 1 };
	if (lastErr.n >= 3) { stats.stall_warnings++; return `\n[STALL WARNING: the same first error has now come back ${lastErr.n} times: \`${sig}\`. Do not repeat the same kind of fix. Make a smaller, local change; or revert_file the file to the phase-start version and re-implement that part in pieces.]`; }
	return "";
}
function budgetNote() {
	const el = (Date.now() - T0) / 1000;
	if (el > WALL_S * 0.7 || stats.steps > MAX_STEPS * 0.7) return `\n[BUDGET: ${Math.round(el / 60)} of ${Math.round(WALL_S / 60)} min and ${stats.steps} of ${MAX_STEPS} steps used. Reach a COMPILING state (revert_file anything half-done), run verify, then output the summary starting with '${MARKER}'.]`;
	return "";
}

export default function (pi: ExtensionAPI) {
	const baseRead = createReadTool(localCwd, { operations: readOps });
	const baseWrite = createWriteTool(localCwd, { operations: writeOps });
	const baseEdit = createEditTool(localCwd, { operations: editOps });
	const text = (t: string, isError = false) => ({ content: [{ type: "text" as const, text: t }], details: {}, isError });

	pi.registerTool({ ...baseRead, description: `Read a file of the workspace (paths relative to the workspace root, e.g. server/src/db.rs). Use offset/limit to read only the region you need (40-80 lines): after ${READ_BUDGET} reads a read returns at most ${POST_BUDGET_LINES} lines.`,
		async execute(id: string, params: any, signal: any, onUpdate: any) {
			stats.reads++;
			if (stats.reads > READ_BUDGET && !(params.limit && params.limit <= POST_BUDGET_LINES)) params = { ...params, limit: POST_BUDGET_LINES };
			return baseRead.execute(id, params, signal, onUpdate);
		} } as any);
	pi.registerTool({ ...baseWrite, description: `Write the COMPLETE content of a file (creates parent dirs, overwrites). Only files in this phase's SCOPE; pass override_reason to touch a file outside it. Capped at ${WRITE_CAP} chars per call: write long files in parts (write, then append_file). Use edit for small changes.`,
		parameters: Type.Object({ path: Type.String(), content: Type.String(), override_reason: Type.Optional(Type.String()) }),
		async execute(id: string, params: any, signal: any, onUpdate: any) {
			if (!inScope(params.path) && !params.override_reason) return text(scopeError(params.path), true);
			if (params.content.length > WRITE_CAP) { stats.oversized_writes++; return text(`ERROR: refused — ${params.content.length} chars is over the single-write cap of ${WRITE_CAP} chars (~${WRITE_CAP >> 2} tokens). Write the first ~250 lines with write, then the rest with append_file. Nothing was written.`, true); }
			stats.writes++; return baseWrite.execute(id, { path: params.path, content: params.content }, signal, onUpdate);
		} } as any);
	pi.registerTool({ ...baseEdit, description: `Make precise edits to a file: each edits[] entry replaces ONE exact occurrence of oldText (copied verbatim, as small as unique) with newText; several disjoint edits of one file go in one call. Only files in this phase's SCOPE; pass override_reason to touch a file outside it.`,
		parameters: Type.Object({ path: Type.String(), edits: Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() })), override_reason: Type.Optional(Type.String()) }),
		async execute(id: string, params: any, signal: any, onUpdate: any) {
			if (!inScope(params.path) && !params.override_reason) return text(scopeError(params.path), true);
			stats.edits++; return baseEdit.execute(id, { path: params.path, edits: params.edits }, signal, onUpdate);
		} } as any);

	pi.registerTool({ name: "list_files", label: "list_files", description: "List every file in the workspace (recursively; target/, dist/, .git excluded) with sizes.", parameters: Type.Object({}),
		async execute() { const r = await ssh(`cd ${q(RCWD)} && find . -type f -not -path './target/*' -not -path '*/dist/*' -not -path './.git/*' -printf '%P %s\\n' | sort`); return text(r.out.trim() || "(empty)"); } });
	pi.registerTool({ name: "append_file", label: "append_file", description: `Append content to the END of an existing file (for writing a long file in parts). Same ${WRITE_CAP}-char cap and SCOPE rule as write.`,
		parameters: Type.Object({ path: Type.String(), content: Type.String(), override_reason: Type.Optional(Type.String()) }),
		async execute(_id: string, p: any) {
			if (!inScope(p.path) && !p.override_reason) return text(scopeError(p.path), true);
			if (p.content.length > WRITE_CAP) { stats.oversized_writes++; return text(`ERROR: refused — ${p.content.length} chars is over the per-call cap of ${WRITE_CAP}; append in smaller parts. Nothing was written.`, true); }
			stats.appends++; const b64 = Buffer.from(p.content).toString("base64");
			const r = await ssh(`cd ${q(RCWD)} && test -f ${q(rel(p.path))} && echo ${b64} | base64 -d >> ${q(rel(p.path))} && wc -l < ${q(rel(p.path))}`);
			return r.code === 0 ? text(`appended; file now ${r.out.trim()} lines`) : text("ERROR: file does not exist (use write first) or append failed: " + r.out.slice(0, 200), true);
		} });
	pi.registerTool({ name: "revert_file", label: "revert_file", description: "Restore a file to its content at the START of this phase (git checkout of the phase-start commit). Use when your edits cascaded into more errors than they fixed; then re-implement in smaller parts.",
		parameters: Type.Object({ path: Type.String(), override_reason: Type.Optional(Type.String()) }),
		async execute(_id: string, p: any) {
			if (!inScope(p.path) && !p.override_reason) return text(scopeError(p.path), true);
			stats.reverts++; const r = await ssh(`cd ${q(RCWD)} && git checkout HEAD -- ${q(rel(p.path))} 2>&1 && echo reverted`);
			return text(r.out.trim() || "ERROR: revert failed", r.code !== 0);
		} });
	const ALLOWED = new Set(["check", "test", "clippy", "build", "run", "fmt", "tree", "metadata"]);
	pi.registerTool({ name: "cargo", label: "cargo", description: 'Run `cargo <command>` at the WORKSPACE root (allowed: check, test, clippy, build, run, fmt, tree). Examples: "test -p server", "test -p server boards_", "check -p engine --target wasm32-unknown-unknown", "clippy --workspace". DATABASE_URL is set. Output is capped; compiler errors are the feedback.',
		parameters: Type.Object({ command: Type.String(), timeout_s: Type.Optional(Type.Number()) }),
		async execute(_id: string, p: any) {
			const parts: string[] = p.command.trim().split(/\s+/); if (!ALLOWED.has(parts[0])) return text(`ERROR: only these cargo subcommands are allowed: ${[...ALLOWED].sort().join(", ")}`, true);
			if (parts[0] === "run") parts.splice(1, 0, "--quiet");
			stats.cargo_runs++; const t = Math.min(Number(p.timeout_s ?? 600), 900);
			const r = await ssh(`${RENV} cd ${q(RCWD)} && timeout ${t} cargo ${parts.map(q).join(" ")} 2>&1`, t + 30);
			if (r.code !== 0) stats.cargo_failures++; const out = r.out.trim();
			return text(`exit=${r.code}\n${out ? clip(out) : "(no output)"}` + (r.code === 124 ? "\nERROR: cargo timed out" : "") + stallNote(out));
		} });
	pi.registerTool({ name: "trunk_build", label: "trunk_build", description: "Run `trunk build --release` in engine/ (wasm32 target + JS glue + index.html into engine/dist/). Output is capped; errors are the feedback.", parameters: Type.Object({}),
		async execute() { stats.trunk_builds++; const r = await ssh(`${RENV} cd ${q(RCWD)}/engine && timeout 900 trunk build --release 2>&1; echo "exit=$?"; ls -l dist 2>/dev/null | tail -n +2 | awk '{print "dist/"$9" "$5" bytes"}'`, 960); return text(clip(r.out.trim())); } });
	pi.registerTool({ name: "verify", label: "verify", description: "The phase's acceptance check (the harness runs the same one after the phase): cargo test --no-fail-fast with pass/fail lists, compile errors, clippy count, MODULE WIRING (every .rs under src/ must be declared with `mod x;`), and with quick=false also the wasm32 check.",
		parameters: Type.Object({ quick: Type.Optional(Type.Boolean()) }),
		async execute(_id: string, p: any) { stats.verify_runs++; const r = await ssh(`${RENV} timeout 1200 python3 ${q(VERIFY)} ${q(RCWD)} ${p.quick === false ? "" : "--quick"} 2>&1 | grep -v '^JSON '`, 1260); const out = r.out.trim(); return text((out.slice(0, 6000) || "(no output)") + stallNote(out)); } });

	// ---- budget: count steps, nudge at 70 %, block at 100 %
	pi.on("tool_call", async () => {
		stats.steps++;
		const el = (Date.now() - T0) / 1000;
		if (stats.steps > MAX_STEPS || el > WALL_S) { stats.blocked_calls++; saveStats(); return { block: true, reason: `Budget exhausted (${stats.steps} steps, ${Math.round(el / 60)} min). No more tool calls: output the summary now, starting with '${MARKER}'.` }; }
	});
	pi.on("tool_result", async (event: any) => {
		const note = budgetNote(); if (!note) return;
		const content = [...(event.content ?? [])]; const last = content[content.length - 1];
		if (last && last.type === "text") content[content.length - 1] = { ...last, text: last.text + note }; else content.push({ type: "text", text: note });
		return { content };
	});
	pi.on("before_provider_request", async (event: any) => {
		stats.requests++; const s = JSON.stringify(event.payload).length; if (s > stats.request_chars_max) stats.request_chars_max = s;
		log("requests.log", `${new Date().toISOString()} req=${stats.requests} chars=${s} messages=${event.payload?.messages?.length ?? "?"}\n`);
		if (stats.requests === 1) log("payload_first.json", JSON.stringify(event.payload, null, 1).slice(0, 200000));
	});
	pi.on("before_agent_start", async (event: any) => { if (stats.requests === 0) log("system_prompt.txt", event.systemPrompt); });
	pi.on("turn_end", async () => saveStats());
	pi.on("agent_end", async (event: any) => {
		saveStats();
		try { const msgs = event.messages ?? []; const lastA = [...msgs].reverse().find((m: any) => m.role === "assistant");
			const t = (lastA?.content ?? []).filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n"); log("final.md", t); stats.marker = t.includes(MARKER); saveStats(); } catch {}
	});
}
