import { describe, expect, afterEach } from "bun:test"
import { mkdirSync, writeFileSync } from "fs"
import path from "path"
import { Effect } from "effect"
import { Session } from "../../src/session"
import { Instance } from "../../src/project/instance"
import { provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { WorkflowRuntime } from "../../src/workflow/runtime"
import { WorkflowChildFailed } from "../../src/workflow/events"
import { Bus } from "../../src/bus"
import { makeLayer, ref, providerCfg } from "./lib"

afterEach(async () => {
  await Instance.disposeAll()
})

const it = testEffect(makeLayer())

describe("WorkflowRuntime file primitives", () => {
  it.live("writeFile then glob+readFile round-trips inside the worktree workspace", () =>
    provideTmpdirServer(
      Effect.fnUntraced(function* () {
        const runtime = yield* WorkflowRuntime.Service
        const session = yield* Session.Service
        const parent = yield* session.create({
          title: "wf files",
          permission: [{ permission: "*", pattern: "*", action: "allow" }],
        })
        const script = [
          `export const meta = { name: "t", description: "d" }`,
          `await writeFile("notes/a.txt", "hello")`,
          `const found = await glob("notes/*.txt")`,
          `const body = await readFile(found[0])`,
          `return { found, body, missing: await readFile("nope.txt"), there: await exists("notes/a.txt") }`,
        ].join("\n")
        const { runID } = yield* runtime.start({ script, sessionID: parent.id, parentActorID: "main", model: ref })
        const outcome = yield* runtime.wait({ runID })
        expect(outcome.status).toBe("completed")
        const r = (outcome as { result: { found: string[]; body: string; missing: unknown; there: boolean } }).result
        expect(r.found).toEqual(["notes/a.txt"])
        expect(r.body).toBe("hello")
        expect(r.missing === null || r.missing === undefined).toBe(true)
        expect(r.there).toBe(true)
      }),
      { git: true, config: providerCfg },
    ),
  )
})

describe("WorkflowRuntime workflow() inline child", () => {
  it.live("an orchestrator runs an inline child and consumes its returned value", () =>
    provideTmpdirServer(
      Effect.fnUntraced(function* ({ llm }) {
        const runtime = yield* WorkflowRuntime.Service
        const session = yield* Session.Service
        const parent = yield* session.create({
          title: "wf nest inline",
          permission: [{ permission: "*", pattern: "*", action: "allow" }],
        })
        // The child runs one agent → "child-done". The parent has no agent of its
        // own; it just runs the child and wraps the result.
        yield* llm.text("child-done")
        const child = [
          `export const meta = { name: "c", description: "d" }`,
          `return await agent("do child work")`,
        ].join("\n")
        const parentScript = [
          `export const meta = { name: "p", description: "d" }`,
          "const r = await workflow(" + JSON.stringify(child) + ", { tag: 1 })",
          "return { wrapped: r }",
        ].join("\n")
        const { runID } = yield* runtime.start({ script: parentScript, sessionID: parent.id, parentActorID: "main", model: ref })
        const outcome = yield* runtime.wait({ runID })
        expect(outcome.status).toBe("completed")
        expect((outcome as { result: { wrapped: string } }).result.wrapped).toBe("child-done")
      }),
      { git: true, config: providerCfg },
    ),
    20000,
  )

  it.live("a child workspace escaping the parent root fails the run (jail containment)", () =>
    provideTmpdirServer(
      Effect.fnUntraced(function* () {
        const runtime = yield* WorkflowRuntime.Service
        const session = yield* Session.Service
        const parent = yield* session.create({
          title: "wf ws escape",
          permission: [{ permission: "*", pattern: "*", action: "allow" }],
        })
        const child = [`export const meta = { name: "c", description: "d" }`, `return 1`].join("\n")
        // The orchestrator tries to root the child at an absolute path outside the
        // parent workspace → resolveInWorkspace throws → the run fails loud.
        // workspace lives in the THIRD positional arg: workflow(spec, args, opts).
        const parentScript = [
          `export const meta = { name: "p", description: "d" }`,
          "return await workflow(" + JSON.stringify(child) + ", null, { workspace: \"/etc\" })",
        ].join("\n")
        const { runID } = yield* runtime.start({ script: parentScript, sessionID: parent.id, parentActorID: "main", model: ref })
        const outcome = yield* runtime.wait({ runID })
        expect(outcome.status).toBe("failed")
        expect((outcome as { error: string }).error).toMatch(/workspace/)
      }),
      { git: true, config: providerCfg },
    ),
    20000,
  )
})

describe("WorkflowRuntime workflow() by name + dataflow", () => {
  it.live("orchestrator resolves two saved workflows by name and threads A's result into B", () =>
    provideTmpdirServer(
      Effect.fnUntraced(function* ({ dir }) {
        const runtime = yield* WorkflowRuntime.Service
        const session = yield* Session.Service
        const parent = yield* session.create({
          title: "wf nest named",
          permission: [{ permission: "*", pattern: "*", action: "allow" }],
        })
        // Write two saved workflows into the project's .WOOZLIT_CODE/workflows.
        const wfDir = path.join(dir, ".WOOZLIT_CODE", "workflows")
        mkdirSync(wfDir, { recursive: true })
        writeFileSync(
          path.join(wfDir, "produce.js"),
          [`export const meta = { name: "produce", description: "d" }`, `return { value: 7 }`].join("\n"),
        )
        writeFileSync(
          path.join(wfDir, "consume.js"),
          [`export const meta = { name: "consume", description: "d" }`, `return (args.value || 0) * 2`].join("\n"),
        )
        const orchestrator = [
          `export const meta = { name: "orch", description: "d" }`,
          `const a = await workflow("produce")`,
          `const b = await workflow("consume", { value: a.value })`,
          `return b`,
        ].join("\n")
        const { runID } = yield* runtime.start({ script: orchestrator, sessionID: parent.id, parentActorID: "main", model: ref })
        const outcome = yield* runtime.wait({ runID })
        expect(outcome.status).toBe("completed")
        expect((outcome as { result: number }).result).toBe(14) // 7 * 2
      }),
      { git: true, config: providerCfg },
    ),
    20000,
  )
})

describe("WorkflowRuntime global concurrency ceiling", () => {
  it.live("two children of 4 agents each: only the global ceiling reaches the LLM at once", () =>
    provideTmpdirServer(
      Effect.fnUntraced(function* ({ llm }) {
        const runtime = yield* WorkflowRuntime.Service
        const session = yield* Session.Service
        const parent = yield* session.create({
          title: "wf global cap",
          permission: [{ permission: "*", pattern: "*", action: "allow" }],
        })
        // DETERMINISTIC concurrency probe (no peak-tracking race): all 8 agents'
        // replies block on one gate promise. The server counts a request the instant
        // it ARRIVES (hits++ before it blocks on the gate), and hits is monotonic — so
        // while the gate is held, llm.calls is EXACTLY the number of agents that
        // acquired a permit and reached the LLM. With a global ceiling of 2, exactly 2
        // arrive and the other 6 wait at the semaphore; a per-run-only cap (the bug)
        // would let each child run 2 → 4 arrive.
        let release = () => {}
        const gate = new Promise<void>((r) => (release = r))
        for (let i = 0; i < 8; i++) yield* llm.hold("done", gate)
        const child = [
          `export const meta = { name: "c", description: "d" }`,
          `const ts = []`,
          `for (let i = 0; i < 4; i++) ts.push(() => agent("x" + i))`,
          `return (await parallel(ts)).length`,
        ].join("\n")
        const orchestrator = [
          `export const meta = { name: "o", description: "d" }`,
          "const c = " + JSON.stringify(child),
          `return await parallel([() => workflow(c), () => workflow(c)])`,
        ].join("\n")
        const { runID } = yield* runtime.start({
          script: orchestrator,
          sessionID: parent.id,
          parentActorID: "main",
          model: ref,
        })
        // Wait until the ceiling is saturated (2 in-flight), then give any
        // erroneously-unblocked agents ample time to also arrive.
        yield* llm.wait(2)
        yield* Effect.sleep("500 millis")
        expect(yield* llm.calls).toBe(2) // EXACTLY the ceiling — 6 still blocked at the semaphore
        // Release the gate so every agent drains; the run completes.
        release()
        const outcome = yield* runtime.wait({ runID })
        expect(outcome.status).toBe("completed")
        expect((outcome as { result: number[] }).result).toEqual([4, 4])
      }),
      // The global ceiling of 2 comes from CONFIG (a pure process/config property),
      // NOT from a per-run start() input — a per-run input could only narrow its own
      // run's cap, never the process-wide global. Config.get() reads this from the
      // tmpdir's WOOZLIT_CODE.json.
      { git: true, config: (url) => ({ ...providerCfg(url), workflow: { maxConcurrentAgents: 2 } }) },
    ),
    30000,
  )
})

describe("WorkflowRuntime workflow() journal (two-level resume)", () => {
  it.live("resuming an orchestrator replays a completed child with zero new spawns", () =>
    provideTmpdirServer(
      Effect.fnUntraced(function* ({ llm, dir }) {
        const runtime = yield* WorkflowRuntime.Service
        const session = yield* Session.Service
        const parent = yield* session.create({
          title: "wf nest resume",
          permission: [{ permission: "*", pattern: "*", action: "allow" }],
        })
        yield* llm.text("child-done")
        // The child appends a marker to a workspace file on EVERY execution, then
        // returns its agent result. The marker is a real side effect NOT covered by
        // any journal (journals only cache agent()/phase/log) — so it counts how many
        // times the child's BODY actually ran. The child's own agent-journal would
        // replay "child-done" on a relaunch, so the result value alone can't tell a
        // parent-journal replay (child skipped) apart from a child relaunch (child
        // body re-runs from its own journal). The marker file is the discriminator.
        const child = [
          `export const meta = { name: "c", description: "d" }`,
          `const prev = (await readFile("runs.txt")) || ""`,
          `await writeFile("runs.txt", prev + "x")`,
          `return await agent("w")`,
        ].join("\n")
        const orchestrator = [
          `export const meta = { name: "o", description: "d" }`,
          "return await workflow(" + JSON.stringify(child) + ")",
        ].join("\n")
        const { runID } = yield* runtime.start({ script: orchestrator, sessionID: parent.id, parentActorID: "main", model: ref })
        const out1 = yield* runtime.wait({ runID })
        expect(out1.status).toBe("completed")
        expect((out1 as { result: unknown }).result).toBe("child-done")
        const callsAfterFirst = yield* llm.calls
        // Resume the orchestrator: the workflow() result is journaled in the PARENT, so
        // the child sub-run is NOT relaunched — its body never re-runs. The orchestrator
        // completes with the cached value, the marker file stays at one "x", and no new
        // LLM request is made (the child never spawns its agent again).
        const r = yield* runtime.resume({ runID })
        expect(r.resumed).toBe(true)
        const out2 = yield* runtime.wait({ runID })
        expect(out2.status).toBe("completed")
        expect((out2 as { result: unknown }).result).toBe("child-done")
        // The discriminating assertions: child body ran exactly once (parent journal
        // short-circuited the relaunch), so the marker is "x" not "xx", and no extra
        // LLM call landed. Without the parent journal, the child would relaunch and
        // rewrite the marker to "xx".
        const marker = yield* Effect.promise(() => Bun.file(path.join(dir, "runs.txt")).text())
        expect(marker).toBe("x")
        expect(yield* llm.calls).toBe(callsAfterFirst)
      }),
      { git: true, config: providerCfg },
    ),
    25000,
  )
})

describe("WorkflowRuntime nested cancel", () => {
  it.live("cancelling the orchestrator cancels its in-flight child run", () =>
    provideTmpdirServer(
      Effect.fnUntraced(function* ({ llm }) {
        const runtime = yield* WorkflowRuntime.Service
        const session = yield* Session.Service
        const parent = yield* session.create({
          title: "wf nest cancel",
          permission: [{ permission: "*", pattern: "*", action: "allow" }],
        })
        yield* llm.hang // the child's agent hangs → child in-flight at cancel
        const child = [`export const meta = { name: "c", description: "d" }`, `return await agent("hang")`].join("\n")
        const orchestrator = [
          `export const meta = { name: "o", description: "d" }`,
          "return await workflow(" + JSON.stringify(child) + ")",
        ].join("\n")
        const { runID } = yield* runtime.start({ script: orchestrator, sessionID: parent.id, parentActorID: "main", model: ref })
        yield* Effect.sleep("400 millis") // let the child launch + spawn its agent
        yield* runtime.cancel({ runID })
        const s = yield* runtime.status({ runID })
        expect(s.status).toBe("cancelled")
        // The child sub-run must ALSO be cancelled (no orphan). list() returns every
        // run in the session; the child is the non-parent run, and it must be cancelled.
        const all = yield* runtime.list({ sessionID: parent.id })
        const child2 = all.find((r) => r.runID !== runID)
        expect(child2?.status).toBe("cancelled")
      }),
      { git: true, config: providerCfg },
    ),
    20000,
  )
})

describe("WorkflowRuntime cycle + depth safety", () => {
  it.live("a self-referential saved workflow fails the run with a cycle error", () =>
    provideTmpdirServer(
      Effect.fnUntraced(function* ({ dir }) {
        const runtime = yield* WorkflowRuntime.Service
        const session = yield* Session.Service
        const parent = yield* session.create({
          title: "wf cycle",
          permission: [{ permission: "*", pattern: "*", action: "allow" }],
        })
        const wfDir = path.join(dir, ".WOOZLIT_CODE", "workflows")
        mkdirSync(wfDir, { recursive: true })
        // loop.js calls itself by name → cycle.
        writeFileSync(
          path.join(wfDir, "loop.js"),
          [`export const meta = { name: "loop", description: "d" }`, `return await workflow("loop")`].join("\n"),
        )
        const top = `export const meta = { name: "o", description: "d" }\nreturn await workflow("loop")`
        const { runID } = yield* runtime.start({ script: top, sessionID: parent.id, parentActorID: "main", model: ref })
        const outcome = yield* runtime.wait({ runID })
        expect(outcome.status).toBe("failed")
        expect((outcome as { error: string }).error).toMatch(/cycle/i)
      }),
      { git: true, config: providerCfg },
    ),
    20000,
  )

  it.live("exceeding maxDepth fails the run with a depth error", () =>
    provideTmpdirServer(
      Effect.fnUntraced(function* () {
        const runtime = yield* WorkflowRuntime.Service
        const session = yield* Session.Service
        const parent = yield* session.create({
          title: "wf depth",
          permission: [{ permission: "*", pattern: "*", action: "allow" }],
        })
        // Nested inline children, started with maxDepth 1 → the 2nd level exceeds it.
        const inner = `export const meta = { name: "i", description: "d" }\nreturn 1`
        const mid = `export const meta = { name: "m", description: "d" }\nreturn await workflow(${JSON.stringify(inner)})`
        const top = `export const meta = { name: "o", description: "d" }\nreturn await workflow(${JSON.stringify(mid)})`
        const { runID } = yield* runtime.start({ script: top, sessionID: parent.id, parentActorID: "main", model: ref, maxDepth: 1 })
        const outcome = yield* runtime.wait({ runID })
        expect(outcome.status).toBe("failed")
        expect((outcome as { error: string }).error).toMatch(/depth/i)
      }),
      { git: true, config: providerCfg },
    ),
    20000,
  )
})

describe("WorkflowRuntime child failure event", () => {
  it.live("a failed child emits WorkflowChildFailed and workflow() resolves to null", () =>

    provideTmpdirServer(
      Effect.fnUntraced(function* () {
        const runtime = yield* WorkflowRuntime.Service
        const session = yield* Session.Service
        const bus = yield* Bus.Service
        const events: Array<{ childRunID: string; status: string }> = []
        yield* bus.subscribeCallback(WorkflowChildFailed, (e) => {
          events.push({ childRunID: e.properties.childRunID, status: e.properties.status })
        })
        const parent = yield* session.create({
          title: "wf child fail",
          permission: [{ permission: "*", pattern: "*", action: "allow" }],
        })
        // The child throws a plain (NON-structural) error → the CHILD run FAILS
        // immediately, deterministically (no agent, no deadline race that could fail
        // the parent first). workflow() sees a runtime "failed" outcome — not a
        // WORKFLOW_STRUCTURAL_ERROR marker — so it resolves to null (never-throw); the
        // parent run COMPLETES "null" and the event records the child's failure.
        const child = [`export const meta = { name: "c", description: "d" }`, `throw new Error("kaboom")`].join("\n")
        const orchestrator = [
          `export const meta = { name: "o", description: "d" }`,
          "const r = await workflow(" + JSON.stringify(child) + ")",
          `return r === null || r === undefined ? "null" : "value"`,
        ].join("\n")
        const { runID } = yield* runtime.start({
          script: orchestrator,
          sessionID: parent.id,
          parentActorID: "main",
          model: ref,
        })
        const outcome = yield* runtime.wait({ runID })
        // The orchestrator itself COMPLETES (workflow() is never-throw) returning "null".
        expect(outcome.status).toBe("completed")
        expect((outcome as { result: string }).result).toBe("null")
        yield* Effect.sleep("100 millis") // let the bus publish settle
        expect(events.length).toBe(1)
        expect(events[0].status).toBe("failed")
      }),
      { git: true, config: providerCfg },
    ),
    20000,
  )
})

describe("WorkflowRuntime config maxDepth", () => {
  it.live("maxDepth from config fails an over-deep nesting (no per-run override)", () =>
    provideTmpdirServer(
      Effect.fnUntraced(function* () {
        const runtime = yield* WorkflowRuntime.Service
        const session = yield* Session.Service
        const parent = yield* session.create({
          title: "wf cfg depth",
          permission: [{ permission: "*", pattern: "*", action: "allow" }],
        })
        // 2 levels of inline nesting; config sets maxDepth 1 → level 2 exceeds it.
        const inner = `export const meta = { name: "i", description: "d" }\nreturn 1`
        const mid = `export const meta = { name: "m", description: "d" }\nreturn await workflow(${JSON.stringify(inner)})`
        const top = `export const meta = { name: "o", description: "d" }\nreturn await workflow(${JSON.stringify(mid)})`
        // No maxDepth in start() — it must come from config.
        const { runID } = yield* runtime.start({ script: top, sessionID: parent.id, parentActorID: "main", model: ref })
        const outcome = yield* runtime.wait({ runID })
        expect(outcome.status).toBe("failed")
        expect((outcome as { error: string }).error).toMatch(/depth/i)
      }),
      { git: true, config: (url) => ({ ...providerCfg(url), workflow: { maxDepth: 1 } }) },
    ),
    20000,
  )
})
