import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"

const urls = new Map<string, AbsolutePath[]>()
let pulls = 0
const discovery = Layer.succeed(
  SkillDiscovery.Service,
  SkillDiscovery.Service.of({
    pull: (url) => {
      pulls++
      return Effect.succeed(urls.get(url) ?? [])
    },
  }),
)
const watcherLayer = Watcher.testLayer
const it = testEffect(
  Layer.mergeAll(
    AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
      [SkillDiscovery.node, discovery],
      [Watcher.node, watcherLayer],
    ]),
    watcherLayer,
  ),
)

function write(directory: string, name: string, description: string) {
  return fs.writeFile(
    path.join(directory, name, "SKILL.md"),
    `---
name: ${name}
description: ${description}
---
# ${name}`,
  )
}

function waitForSkillUpdate() {
  return Effect.gen(function* () {
    const bus = yield* Bus.Service
    const deferred = yield* Deferred.make<void>()
    const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
      Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
      Effect.forkScoped,
    )
    yield* Effect.yieldNow
    return { deferred, fiber }
  })
}

function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
  return Effect.gen(function* () {
    const watcher = yield* Watcher.Test
    expect((yield* watcher.subscriptions()).some(check)).toBe(true)
  })
}

function emitAndWait(update: Watcher.Update) {
  return Effect.gen(function* () {
    const watcher = yield* Watcher.Test
    yield* Effect.acquireUseRelease(
      waitForSkillUpdate(),
      ({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
      ({ fiber }) => Fiber.interrupt(fiber),
    )
  })
}

describe("Skill", () => {
  it.live("publishes updates when skill sources change", () =>
    Effect.gen(function* () {
      const skill = yield* Skill.Service

      yield* Effect.acquireUseRelease(
        waitForSkillUpdate(),
        ({ deferred }) =>
          skill
            .transform((editor) =>
              editor.source({ type: "directory", path: AbsolutePath.make("/tmp/opencode-skills") }),
            )
            .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
        ({ fiber }) => Fiber.interrupt(fiber),
      )
    }),
  )

  it.live("registers sources and resolves later source precedence", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((tmp) =>
        Effect.gen(function* () {
          const first = path.join(tmp.path, "first")
          const second = path.join(tmp.path, "second")
          yield* Effect.promise(async () => {
            await fs.mkdir(path.join(first, "review"), { recursive: true })
            await fs.mkdir(path.join(second, "review"), { recursive: true })
            await write(first, "review", "First")
            await write(second, "review", "Second")
            await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo")
          })

          const skill = yield* Skill.Service
          yield* skill.transform((editor) => {
            editor.source({ type: "directory", path: AbsolutePath.make(first) })
            editor.source({ type: "directory", path: AbsolutePath.make(first) })
            editor.source({ type: "directory", path: AbsolutePath.make(second) })
            expect(editor.list()).toEqual([
              { type: "directory", path: AbsolutePath.make(first) },
              { type: "directory", path: AbsolutePath.make(second) },
            ])
          })

          expect(yield* skill.sources()).toEqual([
            { type: "directory", path: AbsolutePath.make(first) },
            { type: "directory", path: AbsolutePath.make(second) },
          ])
          expect(yield* skill.list()).toEqual([
            Skill.Info.make({
              id: Skill.ID.make("foo"),
              name: Skill.Name.make("foo"),
              slash: true,
              location: AbsolutePath.make(path.join(first, "foo.md")),
              content: "# foo",
            }),
            {
              id: Skill.ID.make("review"),
              name: Skill.Name.make("review"),
              description: "Second",
              location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
              content: "# review",
            },
          ])
        }),
      ),
    ),
  )

  it.live("loads URL sources and filters skills for agents", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((tmp) =>
        Effect.gen(function* () {
          yield* Effect.promise(async () => {
            await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
            await write(tmp.path, "deploy", "Deploy production")
          })
          pulls = 0
          urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)])

          const agents = yield* Agent.Service
          yield* agents.transform((editor) =>
            editor.update(Agent.ID.make("reviewer"), (agent) => {
              agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
            }),
          )

          const skill = yield* Skill.Service
          yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))

          expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
          expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
          expect(pulls).toBe(1)
          expect(Skill.available(yield* skill.list(), (yield* agents.get(Agent.ID.make("reviewer")))!)).toEqual([])
        }),
      ),
    ),
  )

  it.live("parses opencode metadata flags from skill frontmatter", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((tmp) =>
        Effect.gen(function* () {
          yield* Effect.promise(async () => {
            await fs.mkdir(path.join(tmp.path, "manual"), { recursive: true })
            await fs.writeFile(
              path.join(tmp.path, "manual", "SKILL.md"),
              `---
name: manual
description: Manual only
metadata:
  opencode/slash: true
  opencode/autoinvoke: false
---
# manual`,
            )
          })

          const skill = yield* Skill.Service
          yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))

          expect(yield* skill.list()).toEqual([
            {
              id: Skill.ID.make("manual"),
              name: Skill.Name.make("manual"),
              description: "Manual only",
              slash: true,
              autoinvoke: false,
              location: AbsolutePath.make(path.join(tmp.path, "manual", "SKILL.md")),
              content: "# manual",
            },
          ])
        }),
      ),
    ),
  )

  it.live("clears cached skills when sources reload", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((tmp) =>
        Effect.gen(function* () {
          yield* Effect.promise(async () => {
            await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
            await write(tmp.path, "deploy", "Initial deploy")
          })

          const skill = yield* Skill.Service
          yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
          expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")

          yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
          yield* skill.reload()

          expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
        }),
      ),
    ),
  )

  it.live("reloads project sources created after their missing parent", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((tmp) =>
        Effect.gen(function* () {
          const source = path.join(tmp.path, "generated", "skills")
          const file = path.join(source, "deploy", "SKILL.md")
          const skill = yield* Skill.Service
          const bus = yield* Bus.Service
          yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
          expect(yield* skill.list()).toEqual([])

          yield* Effect.promise(async () => {
            await fs.mkdir(path.dirname(file), { recursive: true })
            await write(source, "deploy", "Deploy production")
          })
          yield* Effect.acquireUseRelease(
            waitForSkillUpdate(),
            ({ deferred }) =>
              bus
                .publish(FileSystem.Event.Changed, { file, event: "add" })
                .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
            ({ fiber }) => Fiber.interrupt(fiber),
          )

          expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
        }),
      ),
    ),
  )

  it.live("watches directory sources for added and changed skills", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((tmp) =>
        Effect.gen(function* () {
          yield* Effect.promise(async () => {
            await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
            await write(tmp.path, "deploy", "Initial deploy")
          })

          const skill = yield* Skill.Service
          yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
          expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
          yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)

          const deploy = path.join(tmp.path, "deploy", "SKILL.md")
          yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
          yield* emitAndWait({ type: "update", path: deploy })
          expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")

          yield* Effect.promise(async () => {
            await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
            await write(tmp.path, "review", "Review changes")
          })
          const review = path.join(tmp.path, "review", "SKILL.md")
          yield* emitAndWait({ type: "create", path: review })
          expect((yield* skill.list()).map((item) => item.id)).toEqual([
            Skill.ID.make("deploy"),
            Skill.ID.make("review"),
          ])

          yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
          yield* emitAndWait({ type: "delete", path: review })
          expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
        }),
      ),
    ),
  )

  it.live("watches canonical directories behind symlinked skills", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((tmp) =>
        Effect.gen(function* () {
          const source = path.join(tmp.path, "source")
          const target = path.join(tmp.path, "target", "bro")
          const file = path.join(target, "SKILL.md")
          yield* Effect.promise(async () => {
            await fs.mkdir(source, { recursive: true })
            await fs.mkdir(target, { recursive: true })
            await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
            await fs.symlink(target, path.join(source, "bro"))
          })

          const skill = yield* Skill.Service
          yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
          expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
          yield* expectSubscription((input) => input.type === "directory" && input.path === target)

          yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
          yield* emitAndWait({ type: "update", path: file })
          expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
        }),
      ),
    ),
  )

  it.live("invalidates symlinked sources when their target changes", () =>
    Effect.acquireRelease(
      Effect.promise(() => tmpdir()),
      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
    ).pipe(
      Effect.flatMap((tmp) =>
        Effect.gen(function* () {
          const source = path.join(tmp.path, "source")
          const first = path.join(tmp.path, "first")
          const second = path.join(tmp.path, "second")
          yield* Effect.promise(async () => {
            await fs.mkdir(path.join(first, "bro"), { recursive: true })
            await fs.mkdir(path.join(second, "bro"), { recursive: true })
            await write(first, "bro", "First")
            await write(second, "bro", "Second")
            await fs.symlink(first, source)
          })

          const skill = yield* Skill.Service
          yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
          expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
          yield* expectSubscription((input) => input.type === "directory" && input.path === first)
          yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)

          yield* Effect.promise(async () => {
            await fs.unlink(source)
            await fs.symlink(second, source)
          })
          yield* emitAndWait({ type: "update", path: source })

          expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
          yield* expectSubscription((input) => input.type === "directory" && input.path === second)
        }),
      ),
    ),
  )
})
