import {randomUUID} from 'node:crypto'

import {afterAll, beforeAll, expect, test} from 'bun:test'
import {and, eq} from 'drizzle-orm'

import {db, schema} from '../db/client'
import {
  befriend,
  cleanupCreatedUsers,
  createUser,
  request,
  type TestUser,
} from '../test-fixtures'

// each test creates its own friend pair so friendship state never leaks
const createFriends = async (): Promise<[TestUser, TestUser]> => {
  const [a, b] = await Promise.all([createUser(), createUser()])
  await befriend(a, b)
  return [a, b]
}

const startConversation = async (a: TestUser, b: TestUser) => {
  const res = await request(a, 'POST', '/dms', {userId: b.id})
  expect(res.status).toBe(200)
  return res.json.conversation.id as string
}

let stranger: TestUser

beforeAll(async () => {
  stranger = await createUser()
})

afterAll(cleanupCreatedUsers)

test('friends can start a conversation; get-or-create is idempotent both ways', async () => {
  const [alice, bob] = await createFriends()

  const first = await request(alice, 'POST', '/dms', {userId: bob.id})
  expect(first.status).toBe(200)
  expect(first.json.conversation.peer).toMatchObject({
    id: bob.id,
    handle: bob.handle,
  })
  expect(first.json.conversation.lastMessage).toBeNull()
  expect(first.json.conversation.unreadCount).toBe(0)

  const again = await request(alice, 'POST', '/dms', {userId: bob.id})
  expect(again.json.conversation.id).toBe(first.json.conversation.id)

  const reverse = await request(bob, 'POST', '/dms', {
    userId: alice.id,
  })
  expect(reverse.json.conversation.id).toBe(
    first.json.conversation.id,
  )
  expect(reverse.json.conversation.peer.id).toBe(alice.id)
})

test('non-friends, pending friends, and self cannot start conversations', async () => {
  const [alice] = await createFriends()

  const denied = await request(alice, 'POST', '/dms', {
    userId: stranger.id,
  })
  expect(denied.status).toBe(403)

  const pendingPeer = await createUser()
  await db.insert(schema.friendships).values({
    requesterId: alice.id,
    addresseeId: pendingPeer.id,
    status: 'pending',
  })
  const pending = await request(alice, 'POST', '/dms', {
    userId: pendingPeer.id,
  })
  expect(pending.status).toBe(403)

  const self = await request(alice, 'POST', '/dms', {
    userId: alice.id,
  })
  expect(self.status).toBe(400)

  // a missing target 404s before the friendship check, as on friend
  // requests and trail invites
  const missing = await request(alice, 'POST', '/dms', {
    userId: randomUUID(),
  })
  expect(missing.status).toBe(404)
})

test('participants send messages; text is validated and trimmed', async () => {
  const [alice, bob] = await createFriends()
  const conversationId = await startConversation(alice, bob)

  const sent = await request(
    alice,
    'POST',
    `/dms/${conversationId}/messages`,
    {text: '  hi bob  '},
  )
  expect(sent.status).toBe(200)
  expect(sent.json.message.text).toBe('hi bob')
  expect(sent.json.message.senderId).toBe(alice.id)
  expect(sent.json.message.conversationId).toBe(conversationId)
  expect(sent.json.message.createdAt).toBeString()

  const empty = await request(
    alice,
    'POST',
    `/dms/${conversationId}/messages`,
    {text: '   '},
  )
  expect(empty.status).toBe(400)

  const long = await request(
    alice,
    'POST',
    `/dms/${conversationId}/messages`,
    {text: 'x'.repeat(1001)},
  )
  expect(long.status).toBe(400)

  const denied = await request(
    stranger,
    'POST',
    `/dms/${conversationId}/messages`,
    {text: 'hi'},
  )
  expect(denied.status).toBe(404)
})

test('a send with a client id is idempotent under replay', async () => {
  const [alice, bob] = await createFriends()
  const conversationId = await startConversation(alice, bob)

  const id = randomUUID()
  const body = {id, text: 'sent offline'}
  // the same send replayed (its ack lost to a dropped connection)
  const first = await request(
    alice,
    'POST',
    `/dms/${conversationId}/messages`,
    body,
  )
  const second = await request(
    alice,
    'POST',
    `/dms/${conversationId}/messages`,
    body,
  )
  expect(first.json.message.id).toBe(id)
  expect(second.json.message.id).toBe(id)
  // upserts on the pk instead of doubling
  const rows = await db
    .select({id: schema.messages.id})
    .from(schema.messages)
    .where(eq(schema.messages.id, id))
  expect(rows).toHaveLength(1)
})

test('rejects a client id already used in another conversation', async () => {
  const [alice, bob] = await createFriends()
  const carol = await createUser()
  await befriend(alice, carol)
  const withBob = await startConversation(alice, bob)
  const withCarol = await startConversation(alice, carol)

  const id = randomUUID()
  const mine = await request(
    alice,
    'POST',
    `/dms/${withBob}/messages`,
    {
      id,
      text: 'to bob',
    },
  )
  expect(mine.status).toBe(200)
  // reusing the id in another conversation must never upsert or leak it
  const reused = await request(
    alice,
    'POST',
    `/dms/${withCarol}/messages`,
    {id, text: 'to carol'},
  )
  expect(reused.status).toBe(409)
})

test('unfriending closes the channel', async () => {
  const [alice, bob] = await createFriends()
  const conversationId = await startConversation(alice, bob)

  await db
    .update(schema.friendships)
    .set({status: 'pending'})
    .where(
      and(
        eq(schema.friendships.requesterId, alice.id),
        eq(schema.friendships.addresseeId, bob.id),
      ),
    )

  const sent = await request(
    alice,
    'POST',
    `/dms/${conversationId}/messages`,
    {text: 'hello?'},
  )
  expect(sent.status).toBe(403)
})

test('inbox keeps an unfriended conversation but marks it not-a-friend', async () => {
  const [alice, bob] = await createFriends()
  const conversationId = await startConversation(alice, bob)
  await request(alice, 'POST', `/dms/${conversationId}/messages`, {
    text: 'while friends',
  })

  const open = await request(alice, 'GET', '/dms')
  expect(
    open.json.conversations.find(
      (c: {id: string}) => c.id === conversationId,
    ).peerIsFriend,
  ).toBe(true)

  await db
    .update(schema.friendships)
    .set({status: 'pending'})
    .where(
      and(
        eq(schema.friendships.requesterId, alice.id),
        eq(schema.friendships.addresseeId, bob.id),
      ),
    )

  const closed = await request(alice, 'GET', '/dms')
  const row = closed.json.conversations.find(
    (c: {id: string}) => c.id === conversationId,
  )
  // the thread stays for history, but is flagged closed so the ui goes read-only
  expect(row).toBeDefined()
  expect(row.peerIsFriend).toBe(false)
})

test('inbox lists conversations newest activity first with peer, preview, and unread count', async () => {
  const [alice, bob] = await createFriends()
  const carol = await createUser()
  await befriend(alice, carol)

  const withBob = await startConversation(alice, bob)
  const withCarol = await startConversation(alice, carol)

  await request(alice, 'POST', `/dms/${withBob}/messages`, {
    text: 'to bob',
  })
  await request(carol, 'POST', `/dms/${withCarol}/messages`, {
    text: 'from carol',
  })
  await request(bob, 'POST', `/dms/${withBob}/messages`, {
    text: 'bob replies',
  })

  const inbox = await request(alice, 'GET', '/dms')
  expect(inbox.status).toBe(200)
  const ids = inbox.json.conversations.map((c: {id: string}) => c.id)
  expect(ids.indexOf(withBob)).toBeLessThan(ids.indexOf(withCarol))

  const bobRow = inbox.json.conversations.find(
    (c: {id: string}) => c.id === withBob,
  )
  expect(bobRow.peer).toMatchObject({id: bob.id, handle: bob.handle})
  expect(bobRow.lastMessage.text).toBe('bob replies')
  expect(bobRow.lastMessageAt).toBeString()
  // alice's own message never counts as unread for her
  expect(bobRow.unreadCount).toBe(1)

  const carolRow = inbox.json.conversations.find(
    (c: {id: string}) => c.id === withCarol,
  )
  expect(carolRow.unreadCount).toBe(1)

  // carol has read nothing yet either, but her own send marks her side read
  const carolInbox = await request(carol, 'GET', '/dms')
  const carolReceived = carolInbox.json.conversations.find(
    (c: {id: string}) => c.id === withCarol,
  )
  expect(carolReceived.unreadCount).toBe(0)
})

test('marking read zeroes only the caller side', async () => {
  const [alice, bob] = await createFriends()
  const conversationId = await startConversation(alice, bob)

  await request(bob, 'POST', `/dms/${conversationId}/messages`, {
    text: 'one',
  })
  await request(alice, 'POST', `/dms/${conversationId}/messages`, {
    text: 'two',
  })

  const read = await request(
    alice,
    'PUT',
    `/dms/${conversationId}/read`,
  )
  expect(read.status).toBe(200)

  const aliceInbox = await request(alice, 'GET', '/dms')
  expect(aliceInbox.json.conversations[0].unreadCount).toBe(0)

  // bob still has alice's message unread (his send preceded hers)
  const bobInbox = await request(bob, 'GET', '/dms')
  expect(bobInbox.json.conversations[0].unreadCount).toBe(1)

  const denied = await request(
    stranger,
    'PUT',
    `/dms/${conversationId}/read`,
  )
  expect(denied.status).toBe(404)
})

test('messages paginate by cursor newest first', async () => {
  const [alice, bob] = await createFriends()
  const conversationId = await startConversation(alice, bob)

  for (let i = 0; i < 5; i++) {
    await request(alice, 'POST', `/dms/${conversationId}/messages`, {
      text: `message ${i}`,
    })
  }

  const first = await request(
    alice,
    'GET',
    `/dms/${conversationId}/messages?limit=3`,
  )
  expect(first.status).toBe(200)
  expect(first.json.messages).toHaveLength(3)
  expect(first.json.messages[0].text).toBe('message 4')
  expect(first.json.nextCursor).toBeString()

  const second = await request(
    bob,
    'GET',
    `/dms/${conversationId}/messages?limit=3&cursor=${first.json.nextCursor}`,
  )
  expect(second.json.messages).toHaveLength(2)
  expect(second.json.nextCursor).toBeNull()

  const ids = [...first.json.messages, ...second.json.messages].map(
    (m: {id: string}) => m.id,
  )
  expect(new Set(ids).size).toBe(5)

  const denied = await request(
    stranger,
    'GET',
    `/dms/${conversationId}/messages`,
  )
  expect(denied.status).toBe(404)
})

test('since returns only strictly newer messages, ascending', async () => {
  const [alice, bob] = await createFriends()
  const conversationId = await startConversation(alice, bob)

  const first = await request(
    alice,
    'POST',
    `/dms/${conversationId}/messages`,
    {text: 'older'},
  )
  await request(bob, 'POST', `/dms/${conversationId}/messages`, {
    text: 'newer one',
  })
  await request(bob, 'POST', `/dms/${conversationId}/messages`, {
    text: 'newer two',
  })

  const since = encodeURIComponent(first.json.message.createdAt)
  const polled = await request(
    alice,
    'GET',
    `/dms/${conversationId}/messages?since=${since}`,
  )
  expect(polled.status).toBe(200)
  expect(
    polled.json.messages.map((m: {text: string}) => m.text),
  ).toEqual(['newer one', 'newer two'])
})
