import { afterEach, describe, expect, test } from "bun:test"
import { Effect, Exit, Cause } from "effect"
import { Log } from "../../src/util"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Session } from "../../src/session"
import { WorkflowPersistence } from "../../src/workflow/persistence"
import { WorkflowRuntime } from "../../src/workflow/runtime"
import { workflowRef } from "../../src/workflow/runtime-ref"
import { provideTmpdirServer, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { makeLayer, ref, providerCfg } from "../workflow/lib"

void Log.init({ print: false })

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

describe("workflows routes", () => {
  test("GET /workflows returns [] when the workflow runtime is not running", async () => {
    await using tmp = await tmpdir({ git: true })
    await Instance.provide({
      directory: tmp.path,
      fn: async () => {
        // #given the workflow runtime layer is not running (late-bound ref unset)
        workflowRef.current = undefined

        // #when — a valid session-shaped sessionID (now REQUIRED) is supplied
        const app = Server.Default().app
        const response = await app.request("/workflows?sessionID=ses_16ec185f2ffexEGkbWeMqWSucv", { method: "GET" })

        // #then — runtime absent short-circuits to [] (the session passes validation)
        expect(response.status).toBe(200)
        expect(await response.json()).toEqual([])
      },
    })
  })

  test("POST /workflows/:runID/resume returns { resumed: false } when the runtime is not running", async () => {
    await using tmp = await tmpdir({ git: true })
    await Instance.provide({
      directory: tmp.path,
      fn: async () => {
        // #given the workflow runtime layer is not running
        workflowRef.current = undefined

        // #when — a real minted-shape runID (wf_ + 26 base62) with no persisted run
        const app = Server.Default().app
        const response = await app.request("/workflows/wf_16ec185f2ffexEGkbWeMqWSucv/resume", { method: "POST" })

        // #then
        expect(response.status).toBe(200)
        expect(await response.json()).toEqual({ runID: "wf_16ec185f2ffexEGkbWeMqWSucv", resumed: false })
      },
    })
  })

  // ── P0 (MR104 #3): path traversal via unvalidated runID ──────────────────
  // resume(runID) → readScript(runID) → scriptPath = join(scriptDir, runID + ".js").
  // A traversal runID escapes scriptDir, so the route MUST reject any runID that
  // is not exactly `wf_` + base62. The proof is that the request is REFUSED at the
  // route's param validator (400) — it never reaches the runtime/launch, so no file
  // outside scriptDir is ever opened.
  for (const evil of [
    "../../../etc/passwd",
    "../../foo",
    "/etc/passwd",
    "wf_../../../etc/passwd", // defeats a prefix-only (startsWith "wf") check
    "wf_..", // bare dot-dot after a legit prefix
  ]) {
    test(`POST /workflows/:runID/resume REJECTS traversal runID ${JSON.stringify(evil)}`, async () => {
      await using tmp = await tmpdir({ git: true })
      await Instance.provide({
        directory: tmp.path,
        fn: async () => {
          // #given the runtime is absent — so if validation let this through it would
          // hit the early `{ resumed: false }`; the only way to a 400 is param rejection.
          workflowRef.current = undefined

          // #when
          const app = Server.Default().app
          const response = await app.request(`/workflows/${encodeURIComponent(evil)}/resume`, { method: "POST" })

          // #then — rejected by the param validator before any path.join / file read.
          expect(response.status).toBe(400)
        },
      })
    })
  }

  // ── P0 (MR104 #3): GET /workflows must NOT leak all-session runs ──────────
  test("GET /workflows with NO sessionID returns 400 (does not list all runs)", async () => {
    await using tmp = await tmpdir({ git: true })
    await Instance.provide({
      directory: tmp.path,
      fn: async () => {
        // #given a live-enough runtime is irrelevant: the missing required query param
        // is rejected at the validator. (Keep it unset so a regression that drops the
        // requirement would surface as a 200 [] rather than passing by accident.)
        workflowRef.current = undefined

        // #when — omit sessionID entirely
        const app = Server.Default().app
        const response = await app.request("/workflows", { method: "GET" })

        // #then — rejected, NOT a 200 with the unfiltered all-runs branch.
        expect(response.status).toBe(400)
      },
    })
  })

  test("GET /workflows with a non-session-shaped sessionID returns 400", async () => {
    await using tmp = await tmpdir({ git: true })
    await Instance.provide({
      directory: tmp.path,
      fn: async () => {
        workflowRef.current = undefined
        const app = Server.Default().app
        const response = await app.request("/workflows?sessionID=not-a-session", { method: "GET" })
        expect(response.status).toBe(400)
      },
    })
  })

  // ── P0 (MR104 #3): defense-in-depth at the persistence layer ──────────────
  // readScript / journal IO are reachable from the tool + TUI, not only the HTTP
  // route, so the persistence path functions must themselves refuse a traversal
  // runID. A direct readScript("../../../etc/passwd") must FAIL (the guard throws,
  // surfacing as an Effect defect) rather than open a file outside scriptDir.
  test("WorkflowPersistence.readScript fails safely on a traversal runID (no out-of-dir read)", async () => {
    await using tmp = await tmpdir({ git: true })
    await Instance.provide({
      directory: tmp.path,
      fn: async () => {
        const exit = await Effect.runPromiseExit(WorkflowPersistence.readScript("../../../etc/passwd"))
        // #then — the GUARD must reject it. Asserting only Exit.isFailure would
        // also pass against unguarded code (ENOENT on `…/etc/passwd.js`), so assert
        // the failure carries the guard's message — this fails closed if safeRunID
        // is ever removed.
        expect(Exit.isFailure(exit)).toBe(true)
        if (Exit.isFailure(exit)) {
          expect(Cause.pretty(exit.cause)).toContain("invalid workflow runID")
        }
      },
    })
  })

  test("WorkflowPersistence.readScript still reads a legit wf_ runID", async () => {
    await using tmp = await tmpdir({ git: true })
    await Instance.provide({
      directory: tmp.path,
      fn: async () => {
        // #given a normally-minted runID with a persisted script
        const runID = "wf_16ec185f2ffexEGkbWeMqWSucv"
        await Effect.runPromise(WorkflowPersistence.writeScript(runID, "export const meta = {}\n"))
        // #then — the guard does NOT break the legit path
        const body = await Effect.runPromise(WorkflowPersistence.readScript(runID))
        expect(body).toContain("export const meta")
      },
    })
  })
})

// The LIVE path the TUI /workflows dialog consumes: the route returns REAL run
// data (not the degenerate []). These run under the workflow test layer
// (makeLayer) so WorkflowRuntime.layer is live and populates the module-global
// `workflowRef` that the route reads through. Crucially the data path is the
// process-global `:memory:` DB (test/preload sets WOOZLIT_CODE_DB=:memory:, and the
// storage Client is a per-process singleton): the run writes its WorkflowRunTable
// row there, and the route's handler — run via AppRuntime, NOT this layer — reads
// the SAME DB. So `Server.Default().app.request("/workflows")` genuinely
// exercises the HTTP route end-to-end against a real run. The
// `x-WOOZLIT_CODE-directory` header makes InstanceMiddleware re-enter the SAME cached
// tmpdir Instance the run used (one entry per directory in Instance's cache), so
// resume's re-launch spawns in the run's own session context. We filter list by
// sessionID to stay robust against any residue in the shared in-memory DB.
const it = testEffect(makeLayer())

// Test ORDER is deliberate: the sandbox-free resume test runs FIRST and the
// agent-spawning list test runs LAST. An agent() run forks a quickjs sandbox
// (evalScript) whose teardown is CPU-sensitive — under load the 1ms pump interval
// can starve, leaving the forked fiber uninterruptible at scope close (the
// pre-existing quickjs-under-Bun condition; see runtime.test.ts). Keeping the only
// sandbox-bearing test last means its residue has no following evalScript test to
// destabilize. Verified 0 flakes across 16 process runs UNDER heavy CPU contention
// in this order; the reverse order flakes ~30%+.
//
// NOTE on resume coverage: we assert the resume route over a LIVE runtime returns
// the real { resumed: false } for an unknown run — this proves the route reaches
// the live `runtime.resume()` (distinct from the degenerate case above, where the
// runtime is absent and the route short-circuits before calling resume). We do NOT
// add an HTTP test for the positive { resumed: true } re-launch: a successful
// resume forks a fresh evalScript whose teardown deadlocks under CI CPU load
// regardless of how it is driven (HTTP or the service directly) — a runtime/sandbox
// limitation we cannot fix from a test. The re-launch semantics (resumed: true,
// cached replay, zero-spawn resume) are covered in the warmed-up
// test/workflow/runtime.test.ts suite.
describe("workflows routes — live runtime", () => {
  it.live("POST /workflows/:runID/resume reaches the LIVE runtime (resumed: false for an unknown run)", () =>
    provideTmpdirServer(
      Effect.fnUntraced(function* ({ dir }) {
        // #given the workflow runtime IS live (the layer set the late-bound ref) —
        // this is what distinguishes this from the degenerate test above, where the
        // ref is undefined and the route returns early without calling resume().
        expect(workflowRef.current).toBeDefined()

        // #when — POST resume over the REAL route for a runID with no persisted run.
        // Use a valid minted-shape runID (wf_ + 26 base62) so it passes the param
        // validator and genuinely reaches the live runtime.resume(), which loads no
        // row and returns { resumed: false }. (async wrapper normalizes Hono's
        // `Promise<Response> | Response` overload for Effect.promise.)
        const response = yield* Effect.promise(async () =>
          Server.Default().app.request(`/workflows/wf_00000000000000000000000000/resume`, {
            method: "POST",
            headers: { "x-WOOZLIT_CODE-directory": dir },
          }),
        )

        // #then — the live runtime's real verdict flows back through the HTTP route.
        expect(response.status).toBe(200)
        expect(yield* Effect.promise(() => response.json())).toEqual({
          runID: "wf_00000000000000000000000000",
          resumed: false,
        })
      }),
      { git: true, config: providerCfg },
    ),
  )

  it.live("GET /workflows returns a live run with real status + counters", () =>
    provideTmpdirServer(
      Effect.fnUntraced(function* ({ dir, llm }) {
        const runtime = yield* WorkflowRuntime.Service
        const session = yield* Session.Service
        const parent = yield* session.create({
          title: "wf route live",
          permission: [{ permission: "*", pattern: "*", action: "allow" }],
        })
        yield* llm.text("done") // one reply for the single agent() spawn
        const script = [`export const meta = { name: "t", description: "d" }`, `return await agent("x")`].join("\n")
        const { runID } = yield* runtime.start({ script, sessionID: parent.id, parentActorID: "main", model: ref })
        const outcome = yield* runtime.wait({ runID, timeoutMs: 8000 })
        expect(outcome.status).toBe("completed")

        // #when — drive the REAL HTTP route the TUI list reads. The directory header
        // makes InstanceMiddleware re-enter the same cached tmpdir Instance the run
        // used; sessionID scopes to this run. app.request's Hono overload is
        // `Promise<Response> | Response`, so the async wrapper normalizes it to a
        // Promise for Effect.promise.
        const response = yield* Effect.promise(async () =>
          Server.Default().app.request(`/workflows?sessionID=${parent.id}`, {
            method: "GET",
            headers: { "x-WOOZLIT_CODE-directory": dir },
          }),
        )
        expect(response.status).toBe(200)
        const rows = (yield* Effect.promise(() => response.json())) as Array<{
          runID: string
          status: string
          succeeded: number
        }>

        // #then — the live run is visible over HTTP with its real fields: this is
        // the exact data path the TUI /workflows list reads (counter from a real spawn).
        const row = rows.find((r) => r.runID === runID)
        expect(row).toBeDefined()
        expect(row!.status).toBe("completed")
        expect(row!.succeeded).toBeGreaterThanOrEqual(1)
      }),
      { git: true, config: providerCfg },
    ),
    // Headroom over the default 5s: this exercises a full Instance bootstrap +
    // a real runtime run + an HTTP round-trip, and runs alongside the heavy
    // live workflow/server suites where CI load can push past 5s.
    15000,
  )
})
