import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolDefinitions, waitForTool } from "./lib/tool"
import { Database } from "../src/database/database"
import { EventV2 } from "../src/event"
import { Reference } from "../src/reference"
import { ToolRegistry } from "../src/tool/registry"

const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))

describe("LocationServiceMap", () => {
  it.live("applies ordered plugin config operations during boot", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((dir) =>
        Effect.gen(function* () {
          yield* Effect.promise(() =>
            fs.writeFile(path.join(dir.path, "opencode.json"), JSON.stringify({ plugins: ["-*", "opencode.agent"] })),
          )
          const plugins = yield* Effect.gen(function* () {
            const plugins = yield* PluginV2.Service
            yield* (yield* PluginSupervisor.Service).ready
            return yield* plugins.list()
          }).pipe(
            Effect.scoped,
            Effect.provide(
              LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
            ),
          )

          expect(plugins.map((plugin) => plugin.id)).toEqual([Plugin.ID.make("opencode.agent")])
        }),
      ),
    ),
  )

  it.live("reloads the plugin generation after config updates", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((dir) =>
        Effect.gen(function* () {
          const file = path.join(dir.path, "opencode.json")
          yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
          yield* Effect.gen(function* () {
            const registry = yield* PluginV2.Service
            const supervisor = yield* PluginSupervisor.Service
            yield* supervisor.ready
            expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])

            yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
            for (let attempt = 0; attempt < 100; attempt++) {
              if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.command")) break
              yield* Effect.sleep("20 millis")
            }

            expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.command"])

            yield* Effect.promise(() =>
              fs.writeFile(
                file,
                JSON.stringify({
                  plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")],
                }),
              ),
            )
            for (let attempt = 0; attempt < 100; attempt++) {
              if ((yield* registry.list()).length === 0) break
              yield* Effect.sleep("20 millis")
            }
            expect(yield* registry.list()).toEqual([])

            yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
            for (let attempt = 0; attempt < 100; attempt++) {
              if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.agent")) break
              yield* Effect.sleep("20 millis")
            }
            expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
          }).pipe(
            Effect.scoped,
            Effect.provide(
              LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
            ),
          )
        }),
      ),
    ),
  )

  it.live("routes located events only to their location", () =>
    Effect.acquireRelease(
      Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
      (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
    ).pipe(
      Effect.flatMap(([first, second]) =>
        Effect.scoped(
          Effect.gen(function* () {
            const locations = yield* LocationServiceMap.Service
            const events = yield* EventV2.Service
            const firstRef = Location.Ref.make({ directory: AbsolutePath.make(first.path) })
            const secondRef = Location.Ref.make({ directory: AbsolutePath.make(second.path) })
            const firstContext = yield* locations.contextEffect(firstRef)
            const secondContext = yield* locations.contextEffect(secondRef)
            const received = { first: 0, second: 0 }
            yield* events.subscribe(Config.Event.Updated).pipe(
              Stream.runForEach(() => Effect.sync(() => received.first++)),
              Effect.provideContext(firstContext),
              Effect.forkScoped({ startImmediately: true }),
            )
            yield* events.subscribe(Config.Event.Updated).pipe(
              Stream.runForEach(() => Effect.sync(() => received.second++)),
              Effect.provideContext(secondContext),
              Effect.forkScoped({ startImmediately: true }),
            )
            yield* Effect.sleep("10 millis")

            yield* events.publish(Config.Event.Updated, {}, { location: firstRef })
            yield* Effect.sleep("10 millis")

            expect(received).toEqual({ first: 1, second: 0 })
          }),
        ),
      ),
    ),
  )

  it.live("reuses cached services for constructed and decoded location refs", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((dir) =>
        Effect.scoped(
          Effect.gen(function* () {
            const locations = yield* LocationServiceMap.Service
            const directory = AbsolutePath.make(dir.path)
            const constructed = Location.Ref.make({ directory })
            const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory })

            expect(constructed).toEqual({ directory, workspaceID: undefined })
            expect(decoded).toEqual(constructed)
            expect(Equal.equals(constructed, decoded)).toBe(true)
            expect(Hash.hash(constructed)).toBe(Hash.hash(decoded))
            expect(yield* locations.contextEffect(constructed)).toBe(yield* locations.contextEffect(decoded))
          }),
        ),
      ),
    ),
  )

  it.live("isolates catalog state by location", () =>
    Effect.acquireRelease(
      Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
      (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
    ).pipe(
      Effect.flatMap(([blocked, allowed]) =>
        Effect.gen(function* () {
          const update = (directory: string, providerID: ProviderV2.ID) =>
            Effect.gen(function* () {
              yield* Reference.Service
              const catalog = yield* Catalog.Service
              yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
              const registry = yield* ToolRegistry.Service
              // Tool plugins register during the forked PluginSupervisor boot; wait for
              // every expected tool rather than relying on batch ordering.
              yield* Effect.forEach(
                [
                  "edit",
                  "glob",
                  "grep",
                  "question",
                  "read",
                  "shell",
                  "skill",
                  "subagent",
                  "todowrite",
                  "webfetch",
                  "websearch",
                  "write",
                ],
                (name) => waitForTool(registry, name),
              )
              return {
                providers: yield* catalog.provider.all(),
                tools: yield* toolDefinitions(registry),
              }
            }).pipe(
              Effect.scoped,
              Effect.provide(
                LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
              ),
            )

          const blockedID = ProviderV2.ID.make("blocked-location")
          const allowedID = ProviderV2.ID.make("allowed-location")
          const blockedState = yield* update(blocked.path, blockedID)
          expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
          expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
          expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
            "edit",
            "glob",
            "grep",
            "question",
            "read",
            "shell",
            "skill",
            "subagent",
            "todowrite",
            "webfetch",
            "websearch",
            "write",
          ])
          const allowedState = yield* update(allowed.path, allowedID)
          expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
          expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
          expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
            "edit",
            "glob",
            "grep",
            "question",
            "read",
            "shell",
            "skill",
            "subagent",
            "todowrite",
            "webfetch",
            "websearch",
            "write",
          ])
        }),
      ),
    ),
  )

  it.live("rejects an unavailable selected model during location model resolution", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((dir) =>
        Effect.gen(function* () {
          const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
          yield* Effect.promise(() =>
            fs.writeFile(
              path.join(dir.path, "opencode.json"),
              JSON.stringify({
                providers: {
                  unavailable: {
                    name: "Unavailable",
                    api: { type: "native", settings: {} },
                    models: { chat: { disabled: true } },
                  },
                },
              }),
            ),
          )
          const failure = yield* SessionRunnerModel.Service.use((models) =>
            models.resolve(
              SessionV2.Info.make({
                id: SessionV2.ID.make("ses_unavailable_model"),
                projectID: ProjectV2.ID.global,
                title: "test",
                model: {
                  id: ModelV2.ID.make("chat"),
                  providerID: ProviderV2.ID.make("unavailable"),
                },
                cost: 0,
                tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
                time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
                location,
              }),
            ),
          ).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)

          expect(failure).toMatchObject({
            _tag: "SessionRunnerModel.ModelUnavailableError",
            providerID: "unavailable",
            modelID: "chat",
          })
        }),
      ),
    ),
  )

  it.live("preserves the selected catalog identity when the api model id differs", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((dir) =>
        Effect.gen(function* () {
          const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
          const resolved = yield* Effect.gen(function* () {
            const catalog = yield* Catalog.Service
            yield* catalog.transform((editor) => {
              editor.provider.update(ProviderV2.ID.make("aliased"), (provider) => {
                provider.api = { type: "aisdk", package: "@ai-sdk/openai", settings: {} }
              })
              editor.model.update(ProviderV2.ID.make("aliased"), ModelV2.ID.make("fast"), (model) => {
                // Catalog id and provider API id intentionally differ, like gpt-5.5-fast -> gpt-5.5.
                model.api = { ...model.api, id: ModelV2.ID.make("base") }
                model.variants.push({ id: ModelV2.VariantID.make("high"), settings: {}, headers: {}, body: {} })
              })
            })
            const models = yield* SessionRunnerModel.Service
            return yield* models.resolve(
              SessionV2.Info.make({
                id: SessionV2.ID.make("ses_aliased_model"),
                projectID: ProjectV2.ID.global,
                title: "test",
                model: {
                  id: ModelV2.ID.make("fast"),
                  providerID: ProviderV2.ID.make("aliased"),
                  variant: ModelV2.VariantID.make("high"),
                },
                cost: 0,
                tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
                time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
                location,
              }),
            )
          }).pipe(Effect.provide(LocationServiceMap.Service.get(location)))

          expect(resolved.ref).toEqual(
            ModelV2.Ref.make({
              id: ModelV2.ID.make("fast"),
              providerID: ProviderV2.ID.make("aliased"),
              variant: ModelV2.VariantID.make("high"),
            }),
          )
          expect(String(resolved.model.id)).toBe("base")
        }),
      ),
    ),
  )

  it.live("installs public plugins into a location", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((dir) =>
        Effect.gen(function* () {
          const plugins = yield* PluginV2.Service
          const reviewer = define({
            id: "reviewer",
            effect: (ctx) =>
              ctx.agent
                .transform((agent) => {
                  agent.update("reviewer", (item) => {
                    item.description = "Reviews code"
                    item.mode = "subagent"
                  })
                })
                .pipe(Effect.asVoid),
          })
          yield* plugins.activate([{ plugin: reviewer }])

          expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
            description: "Reviews code",
            mode: "subagent",
          })
        }).pipe(
          Effect.scoped,
          Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
        ),
      ),
    ),
  )
})
