import { describe, expect, beforeEach, afterEach, afterAll } from "bun:test"
import { provideTmpdirInstance } from "../fixture/fixture"
import { Effect, Layer, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../src/bus"
import { SyncEvent } from "../../src/sync"
import { Database, eq } from "@/storage/db"
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
import { MessageID } from "../../src/session/schema"
import { Flag } from "@opencode-ai/core/flag/flag"
import { initProjectors } from "../../src/server/projectors"
import { testEffect } from "../lib/effect"

const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const it = testEffect(Layer.mergeAll(SyncEvent.defaultLayer, CrossSpawnSpawner.defaultLayer))

beforeEach(() => {
  Database.close()

  Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
})

afterEach(() => {
  Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
})

describe("SyncEvent", () => {
  function setup() {
    SyncEvent.reset()

    const Created = SyncEvent.define({
      type: "item.created",
      version: 1,
      aggregate: "id",
      schema: Schema.Struct({ id: Schema.String, name: Schema.String }),
    })
    const Sent = SyncEvent.define({
      type: "item.sent",
      version: 1,
      aggregate: "item_id",
      schema: Schema.Struct({ item_id: Schema.String, to: Schema.String }),
    })

    SyncEvent.init({
      projectors: [SyncEvent.project(Created, () => {}), SyncEvent.project(Sent, () => {})],
    })

    return { Created, Sent }
  }

  function expectDefect<A, E, R>(effect: Effect.Effect<A, E, R>, pattern: RegExp) {
    return Effect.gen(function* () {
      const exit = yield* Effect.exit(effect)
      if (exit._tag === "Success") throw new Error("Expected effect to fail")
      expect(String(exit.cause)).toMatch(pattern)
    })
  }

  afterAll(() => {
    SyncEvent.reset()
    initProjectors()
  })

  describe("run", () => {
    it.live(
      "inserts event row",
      provideTmpdirInstance(() =>
        Effect.gen(function* () {
          const { Created } = setup()
          yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" })
          const rows = Database.use((db) => db.select().from(EventTable).all())
          expect(rows).toHaveLength(1)
          expect(rows[0].type).toBe("item.created.1")
          expect(rows[0].aggregate_id).toBe("evt_1")
        }),
      ),
    )

    it.live(
      "increments seq per aggregate",
      provideTmpdirInstance(() =>
        Effect.gen(function* () {
          const { Created } = setup()
          yield* SyncEvent.use.run(Created, { id: "evt_1", name: "first" })
          yield* SyncEvent.use.run(Created, { id: "evt_1", name: "second" })
          const rows = Database.use((db) => db.select().from(EventTable).all())
          expect(rows).toHaveLength(2)
          expect(rows[1].seq).toBe(rows[0].seq + 1)
        }),
      ),
    )

    it.live(
      "uses custom aggregate field from agg()",
      provideTmpdirInstance(() =>
        Effect.gen(function* () {
          const { Sent } = setup()
          yield* SyncEvent.use.run(Sent, { item_id: "evt_1", to: "james" })
          const rows = Database.use((db) => db.select().from(EventTable).all())
          expect(rows).toHaveLength(1)
          expect(rows[0].aggregate_id).toBe("evt_1")
        }),
      ),
    )

    it.live(
      "emits events",
      provideTmpdirInstance(() =>
        Effect.gen(function* () {
          const { Created } = setup()
          const events: Array<{
            type: string
            properties: { id: string; name: string }
          }> = []
          let resolve = () => {}
          const received = new Promise<void>((done) => {
            resolve = done
          })
          const dispose = Bus.subscribeAll((event) => {
            events.push(event)
            resolve()
          })
          try {
            yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" })
            yield* Effect.promise(() => received)
            expect(events).toHaveLength(1)
            expect(events[0]).toMatchObject({
              type: "item.created",
              properties: {
                id: "evt_1",
                name: "test",
              },
            })
          } finally {
            dispose()
          }
        }),
      ),
    )
  })

  describe("replay", () => {
    it.live(
      "inserts event from external payload",
      provideTmpdirInstance(() =>
        Effect.gen(function* () {
          const id = MessageID.ascending()
          yield* SyncEvent.use.replay({
            id: "evt_1",
            type: "item.created.1",
            seq: 0,
            aggregateID: id,
            data: { id, name: "replayed" },
          })
          const rows = Database.use((db) => db.select().from(EventTable).all())
          expect(rows).toHaveLength(1)
          expect(rows[0].aggregate_id).toBe(id)
        }),
      ),
    )

    it.live(
      "throws on sequence mismatch",
      provideTmpdirInstance(() =>
        Effect.gen(function* () {
          const id = MessageID.ascending()
          yield* SyncEvent.use.replay({
            id: "evt_1",
            type: "item.created.1",
            seq: 0,
            aggregateID: id,
            data: { id, name: "first" },
          })
          yield* expectDefect(
            SyncEvent.use.replay({
              id: "evt_1",
              type: "item.created.1",
              seq: 5,
              aggregateID: id,
              data: { id, name: "bad" },
            }),
            /Sequence mismatch/,
          )
        }),
      ),
    )

    it.live(
      "throws on unknown event type",
      provideTmpdirInstance(() =>
        Effect.gen(function* () {
          yield* expectDefect(
            SyncEvent.use.replay({
              id: "evt_1",
              type: "unknown.event.1",
              seq: 0,
              aggregateID: "x",
              data: {},
            }),
            /Unknown event type/,
          )
        }),
      ),
    )

    it.live(
      "replayAll accepts later chunks after the first batch",
      provideTmpdirInstance(() =>
        Effect.gen(function* () {
          const { Created } = setup()
          const id = MessageID.ascending()

          const one = yield* SyncEvent.use.replayAll([
            {
              id: "evt_1",
              type: SyncEvent.versionedType(Created.type, Created.version),
              seq: 0,
              aggregateID: id,
              data: { id, name: "first" },
            },
            {
              id: "evt_2",
              type: SyncEvent.versionedType(Created.type, Created.version),
              seq: 1,
              aggregateID: id,
              data: { id, name: "second" },
            },
          ])

          const two = yield* SyncEvent.use.replayAll([
            {
              id: "evt_3",
              type: SyncEvent.versionedType(Created.type, Created.version),
              seq: 2,
              aggregateID: id,
              data: { id, name: "third" },
            },
            {
              id: "evt_4",
              type: SyncEvent.versionedType(Created.type, Created.version),
              seq: 3,
              aggregateID: id,
              data: { id, name: "fourth" },
            },
          ])

          expect(one).toBe(id)
          expect(two).toBe(id)

          const rows = Database.use((db) => db.select().from(EventTable).all())
          expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
        }),
      ),
    )

    it.live(
      "claims unowned event sequence on replay with ownerID",
      provideTmpdirInstance(() =>
        Effect.gen(function* () {
          const { Created } = setup()
          const id = MessageID.ascending()

          yield* SyncEvent.use.replay(
            {
              id: "evt_1",
              type: SyncEvent.versionedType(Created.type, Created.version),
              seq: 0,
              aggregateID: id,
              data: { id, name: "owned" },
            },
            { publish: false, ownerID: "owner-1" },
          )

          const row = Database.use((db) =>
            db
              .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
              .from(EventSequenceTable)
              .get(),
          )
          expect(row).toEqual({ seq: 0, ownerID: "owner-1" })
        }),
      ),
    )

    it.live(
      "ignores replay from a different owner after sequence is claimed",
      provideTmpdirInstance(() =>
        Effect.gen(function* () {
          const { Created } = setup()
          const id = MessageID.ascending()

          yield* SyncEvent.use.replay(
            {
              id: "evt_1",
              type: SyncEvent.versionedType(Created.type, Created.version),
              seq: 0,
              aggregateID: id,
              data: { id, name: "first" },
            },
            { publish: false, ownerID: "owner-1" },
          )
          yield* SyncEvent.use.replay(
            {
              id: "evt_2",
              type: SyncEvent.versionedType(Created.type, Created.version),
              seq: 1,
              aggregateID: id,
              data: { id, name: "ignored" },
            },
            { publish: false, ownerID: "owner-2" },
          )

          const events = Database.use((db) => db.select().from(EventTable).all())
          const sequence = Database.use((db) =>
            db
              .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
              .from(EventSequenceTable)
              .get(),
          )
          expect(events).toHaveLength(1)
          expect(events[0].id).toBe("evt_1")
          expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
        }),
      ),
    )

    it.live(
      "claim updates the event sequence owner",
      provideTmpdirInstance(() =>
        Effect.gen(function* () {
          const { Created } = setup()
          const id = MessageID.ascending()

          yield* SyncEvent.use.run(Created, { id, name: "claimed" }, { publish: false })
          yield* SyncEvent.use.claim(id, "owner-1")
          yield* SyncEvent.use.claim(id, "owner-2")

          const row = Database.use((db) =>
            db
              .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
              .from(EventSequenceTable)
              .where(eq(EventSequenceTable.aggregate_id, id))
              .get(),
          )
          expect(row).toEqual({ seq: 0, ownerID: "owner-2" })
        }),
      ),
    )
  })
})
