import fs from "fs/promises"
import path from "path"
import { expect } from "bun:test"
import { Deferred, Effect, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
import { testEffect } from "../../core/test/lib/effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import type { OpenCodeEvent } from "../src"

const it = testEffect(Layer.empty)
type Sdk = typeof import("../src")
type Fixture = { readonly directory: string; readonly sdk: Sdk }

const withEmbedded = <A, E, R>(prefix: string, f: (fixture: Fixture) => Effect.Effect<A, E, R>) =>
  Effect.acquireRelease(
    Effect.promise(() => tmpdir(prefix)),
    (directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
  ).pipe(
    Effect.flatMap((directory) =>
      Effect.promise(() => import("../src")).pipe(Effect.flatMap((sdk) => f({ directory: directory.path, sdk }))),
    ),
  )

const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()

const location = (fixture: Fixture) =>
  fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })

it.live("exposes app metadata to plugins", () =>
  withEmbedded("opencode-embedded-app-", (fixture) =>
    Effect.gen(function* () {
      const opencode = yield* fixture.sdk.OpenCode.create({
        app: { name: "test", version: "1.2.3", channel: "beta" },
      })
      const app = yield* Deferred.make<{ readonly name: string; readonly version: string; readonly channel: string }>()
      yield* opencode.plugin({
        id: `app-${crypto.randomUUID()}`,
        effect: (ctx) => Deferred.succeed(app, ctx.app).pipe(Effect.asVoid),
      })
      yield* opencode.plugin.list({ location: location(fixture) })
      expect(yield* Deferred.await(app).pipe(Effect.timeout("4 seconds"))).toEqual({
        name: "test",
        version: "1.2.3",
        channel: "beta",
      })
    }),
  ),
)

it.live(
  "reloads every booted Location after SDK plugin registration",
  () =>
    withEmbedded("opencode-embedded-plugin-reload-", (fixture) =>
      Effect.gen(function* () {
        const opencode = yield* fixture.sdk.OpenCode.create()
        const booted = yield* Deferred.make<void>()
        const activated = yield* Deferred.make<boolean>()
        const bootCount = yield* Ref.make(0)
        const activationCount = yield* Ref.make(0)
        const secondDirectory = path.join(fixture.directory, "second")
        yield* Effect.promise(() => fs.mkdir(secondDirectory))
        const refs = [
          location(fixture),
          fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(secondDirectory) }),
        ]
        const bootstrapID = `bootstrap-sdk-${crypto.randomUUID()}`
        const id = `late-sdk-${crypto.randomUUID()}`

        yield* opencode.plugin({
          id: bootstrapID,
          effect: (ctx) =>
            Effect.gen(function* () {
              yield* ctx.tool
                .transform((draft) =>
                  draft.add(
                    ({
                      name: "bootstrap_sdk_tool",
                      description: "Marks the initial Location plugin generation",
                      input: Schema.Struct({}),
                      output: Schema.Void,
                      execute: () => Effect.succeed({ output: undefined }),
                    }),
                  ),
                )
                .pipe(Effect.orDie)
              if (yield* Ref.updateAndGet(bootCount, (count) => count + 1).pipe(Effect.map((count) => count === 2))) {
                yield* Deferred.succeed(booted, undefined)
              }
            }),
        })
        yield* Effect.all(
          refs.map((ref) => opencode.plugin.list({ location: ref })),
          { discard: true },
        )
        yield* Deferred.await(booted).pipe(Effect.timeout("4 seconds"))
        yield* opencode.plugin({
          id,
          effect: (ctx) =>
            Effect.gen(function* () {
              yield* ctx.tool
                .transform((draft) =>
                  draft.add(
                    ({
                      name: "late_sdk_tool",
                      description: "Tool registered after Location boot",
                      input: Schema.Struct({}),
                      output: Schema.Void,
                      execute: () => Effect.succeed({ output: undefined }),
                    }),
                  ),
                )
                .pipe(Effect.orDie)
              if (
                yield* Ref.updateAndGet(activationCount, (count) => count + 1).pipe(Effect.map((count) => count === 2))
              ) {
                yield* Deferred.succeed(activated, true)
              }
            }),
        })

        expect(yield* Deferred.await(activated).pipe(Effect.timeout("10 seconds"))).toBe(true)
      }),
    ),
  25_000,
)

it.live(
  "preserves SDK plugins across Location eviction",
  () =>
    withEmbedded("opencode-embedded-plugin-eviction-", (fixture) =>
      Effect.gen(function* () {
        const opencode = yield* fixture.sdk.OpenCode.create()
        const ref = location(fixture)
        const connected = yield* Latch.make(false)
        const booted = yield* Deferred.make<void>()
        // The rebooted Location commits its second plugin generation.
        const recommitted = yield* Deferred.make<void>()
        const generations = yield* Ref.make(0)
        const id = `evicted-sdk-${crypto.randomUUID()}`

        yield* opencode.events.subscribe().pipe(
          Stream.runForEach((event) => {
            if (event.type === "server.connected") return connected.open
            if (event.type !== "plugin.updated" || event.location?.directory !== fixture.directory) return Effect.void
            return Ref.updateAndGet(generations, (total) => total + 1).pipe(
              Effect.flatMap((total) => {
                if (total === 1) return Deferred.succeed(booted, undefined)
                if (total === 2) return Deferred.succeed(recommitted, undefined)
                return Effect.void
              }),
              Effect.asVoid,
            )
          }),
          Effect.forkScoped,
        )
        yield* connected.await
        yield* opencode.plugin({ id, effect: () => Effect.void })

        yield* opencode.plugin.list({ location: ref })
        yield* Deferred.await(booted).pipe(Effect.timeout("5 seconds"))
        yield* opencode.debug.location.evict({ location: ref })
        yield* opencode.plugin.list({ location: ref })
        yield* Deferred.await(recommitted).pipe(Effect.timeout("5 seconds"))

        expect((yield* opencode.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).toContain(id)
      }),
    ),
  15_000,
)

it.live(
  "keeps SDK plugin registration isolated between embedded hosts",
  () =>
    withEmbedded("opencode-embedded-plugin-isolation-", (fixture) =>
      Effect.gen(function* () {
        const first = yield* fixture.sdk.OpenCode.create()
        const second = yield* fixture.sdk.OpenCode.create()
        const firstReady = yield* Deferred.make<void>()
        const secondReady = yield* Deferred.make<void>()
        const activated = yield* Deferred.make<void>()
        const ref = location(fixture)
        const id = `isolated-sdk-${crypto.randomUUID()}`

        yield* first.plugin({
          id: `first-ready-${crypto.randomUUID()}`,
          effect: () => Deferred.succeed(firstReady, undefined),
        })
        yield* second.plugin({
          id: `second-ready-${crypto.randomUUID()}`,
          effect: () => Deferred.succeed(secondReady, undefined),
        })
        yield* Effect.all([first.plugin.list({ location: ref }), second.plugin.list({ location: ref })], {
          discard: true,
        })
        yield* Effect.all([Deferred.await(firstReady), Deferred.await(secondReady)], { discard: true })

        yield* first.plugin({ id, effect: () => Deferred.succeed(activated, undefined) })
        yield* Deferred.await(activated).pipe(Effect.timeout("5 seconds"))

        expect((yield* second.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).not.toContain(id)
      }),
    ),
  15_000,
)

it.live(
  "embedded client uses the real router and handlers",
  () =>
    withEmbedded("opencode-embedded-", (fixture) =>
      Effect.gen(function* () {
        const opencode = yield* fixture.sdk.OpenCode.create({ events: { persist: true } })
        const id = sessionID(fixture)
        const model = fixture.sdk.Model.Ref.make({
          id: fixture.sdk.Model.ID.make("embedded"),
          providerID: fixture.sdk.Provider.ID.make("test"),
        })

        yield* opencode.plugin({
          id: `embedded-tools-${crypto.randomUUID()}`,
          effect: (ctx) =>
            ctx.tool
              .transform((draft) =>
                draft.add(
                  ({
                    name: "embedded_tool",
                    description: "Embedded test tool",
                    input: Schema.Struct({}),
                    output: Schema.Struct({ ok: Schema.Boolean }),
                    execute: () => Effect.succeed({ output: { ok: true } }),
                  }),
                ),
              )
              .pipe(Effect.orDie),
        })

        const created = yield* opencode.sessions.create({
          id,
          agent: fixture.sdk.Agent.ID.make("build"),
          location: location(fixture),
        })
        yield* opencode.sessions.switchModel({ sessionID: id, model })
        const selected = yield* opencode.sessions.get({ sessionID: id })
        const page = yield* opencode.sessions.list({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
        const active = yield* opencode.sessions.active()
        const admitted = yield* opencode.sessions.prompt({
          sessionID: id,
          text: "Do not run",
          resume: false,
        })
        const context = yield* opencode.sessions.context({ sessionID: id })
        const pendingAfterAdmit = yield* opencode.sessions.pending.list({ sessionID: id })
        yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "deploy-target", value: "production" })
        yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "flags", value: { beta: true } })
        const contextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
        yield* opencode.sessions.instructions.entry.remove({ sessionID: id, key: "flags" })
        const remainingContextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
        const wake = yield* opencode.sessions.prompt({
          sessionID: id,
          text: "Promote this input",
        })
        const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe(
          Stream.filter((event) => event.type === "session.input.promoted" && event.data.inputID === wake.id),
          Stream.runHead,
          Effect.timeout("10 seconds"),
          Effect.map(Option.getOrThrow),
        )
        const wakeContext = yield* opencode.sessions.context({ sessionID: id })
        const pendingAfterPromote = yield* opencode.sessions.pending.list({ sessionID: id })
        const event = yield* opencode.sessions.log({ sessionID: id }).pipe(
          Stream.filter((item) => item.type === "session.model.selected"),
          Stream.take(1),
          Stream.runHead,
          Effect.map(Option.getOrUndefined),
        )
        const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe(
          Option.getOrThrow,
        )
        const message = yield* opencode.sessions.message({ sessionID: id, messageID: modelMessage.id })
        yield* opencode.sessions.interrupt({ sessionID: id })
        const other = yield* opencode.sessions.create({ location: location(fixture) })
        const missingSessionID = fixture.sdk.Session.ID.create()
        const missing = yield* Effect.all(
          [
            opencode.sessions.log({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
            opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
            opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
            opencode.sessions.instructions.entry.list({ sessionID: missingSessionID }).pipe(Effect.flip),
            opencode.sessions.pending.list({ sessionID: missingSessionID }).pipe(Effect.flip),
          ],
          { concurrency: "unbounded" },
        )
        const missingMessage = yield* Effect.flip(
          opencode.sessions.message({
            sessionID: other.id,
            messageID: modelMessage.id,
          }),
        )

        expect(created.id).toBe(id)
        expect(selected.model?.id).toBe(model.id)
        expect(selected.model?.providerID).toBe(model.providerID)
        expect(page.data.some((session) => session.id === id)).toBe(true)
        expect(active).toEqual({})
        expect(admitted.sessionID).toBe(id)
        expect(pendingAfterAdmit).toContainEqual(
          expect.objectContaining({ id: admitted.id, type: "user", delivery: "steer" }),
        )
        expect(prompted.type).toBe("session.input.promoted")
        expect(pendingAfterPromote.map((item) => item.id)).not.toContainAnyValues([admitted.id, wake.id])
        expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
        expect(contextEntries).toEqual([
          { key: "deploy-target", value: "production" },
          { key: "flags", value: { beta: true } },
        ])
        expect(remainingContextEntries).toEqual([{ key: "deploy-target", value: "production" }])
        expect(context.some((message) => message.type === "model-switched")).toBe(true)
        expect(event).toMatchObject({ type: "session.model.selected", durable: { seq: 1 } })
        expect(message).toEqual(modelMessage)
        expect(missing.map((error) => error._tag)).toEqual([
          "SessionNotFoundError",
          "SessionNotFoundError",
          "SessionNotFoundError",
          "SessionNotFoundError",
          "SessionNotFoundError",
        ])
        expect(missingMessage._tag).toBe("MessageNotFoundError")
      }),
    ),
  10_000,
)

it.live("embedded client exposes plugin-backed web search", () =>
  withEmbedded("opencode-embedded-websearch-", (fixture) =>
    Effect.gen(function* () {
      const opencode = yield* fixture.sdk.OpenCode.create()
      const providerID = fixture.sdk.WebSearch.ID.make("embedded-websearch")
      yield* opencode.plugin({
        id: `embedded-websearch-${crypto.randomUUID()}`,
        effect: (ctx) =>
          ctx.websearch.transform((draft) => {
            draft.add({
              id: providerID,
              name: "Embedded web search",
              execute: (input) =>
                Effect.succeed([{ url: "https://example.com", content: `Found ${input.query}`, time: {} }]),
            })
          }),
      })

      const result = yield* opencode.websearch.query({
        query: "opencode",
        providerID,
        location: location(fixture),
      })

      expect(result.data).toEqual({
        providerID,
        results: [{ url: "https://example.com", content: "Found opencode", time: {} }],
      })
    }),
  ),
)

it.live(
  "Location-owned runner events reach the ready global client",
  () =>
    withEmbedded("opencode-embedded-events-", (fixture) =>
      Effect.gen(function* () {
        const opencode = yield* fixture.sdk.OpenCode.create()
        const id = sessionID(fixture)
        const connected = yield* Latch.make(false)
        const prompted = yield* Deferred.make<Extract<OpenCodeEvent, { type: "session.input.promoted" }>>()

        yield* opencode.events.subscribe().pipe(
          Stream.runForEach((event) =>
            event.type === "server.connected"
              ? connected.open
              : event.type === "session.input.promoted" && event.data.sessionID === id
                ? Deferred.succeed(prompted, event).pipe(Effect.asVoid)
                : Effect.void,
          ),
          Effect.forkScoped,
        )
        yield* connected.await
        yield* opencode.sessions.create({ id, location: location(fixture) })
        yield* opencode.sessions.prompt({
          sessionID: id,
          text: "Observe this input",
        })

        const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds"))
        expect(event.durable).toEqual(expect.objectContaining({ aggregateID: id, seq: expect.any(Number) }))
      }),
    ),
  10_000,
)

it.live(
  "independent embedded hosts do not share live notifications",
  () =>
    withEmbedded("opencode-embedded-hosts-", (fixture) =>
      Effect.gen(function* () {
        const first = yield* fixture.sdk.OpenCode.create()
        const second = yield* fixture.sdk.OpenCode.create()
        const id = sessionID(fixture)
        const firstReady = yield* Latch.make(false)
        const secondReady = yield* Latch.make(false)
        const firstEvent = yield* Latch.make(false)
        const secondEvent = yield* Latch.make(false)
        const observe = (ready: Latch.Latch, event: Latch.Latch) =>
          Stream.runForEach((notification: OpenCodeEvent) =>
            notification.type === "server.connected"
              ? ready.open
              : notification.type === "session.agent.selected" && notification.data.sessionID === id
                ? event.open
                : Effect.void,
          )

        yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped)
        yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped)
        yield* Effect.all([firstReady.await, secondReady.await], { discard: true })
        yield* first.sessions.create({ id, location: location(fixture) })
        yield* first.sessions.switchAgent({ sessionID: id, agent: fixture.sdk.Agent.ID.make("plan") })

        yield* firstEvent.await.pipe(Effect.timeout("2 seconds"))
        expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true)
      }),
    ),
  10_000,
)

it.live("embedded client is available as a Layer service", () =>
  withEmbedded("opencode-embedded-layer-", (fixture) => {
    const id = sessionID(fixture)
    return Effect.gen(function* () {
      const opencode = yield* fixture.sdk.OpenCode.Service
      const created = yield* opencode.sessions.create({ id, location: location(fixture) })
      expect(created.id).toBe(id)
    }).pipe(Effect.provide(fixture.sdk.OpenCode.layer))
  }),
)
