import {randomUUID} from 'node:crypto'

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

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

let owner: TestUser
let collaborator: TestUser
let stranger: TestUser

beforeAll(async () => {
  ;[owner, collaborator, stranger] = await Promise.all([
    createUser(),
    createUser(),
    createUser(),
  ])
})

afterAll(cleanupCreatedUsers)

const createTrail = async () => {
  const trail = await request(owner, 'POST', '/trails', {
    name: 'Posts trail',
  })
  const trailId = trail.json.trail.id as string
  await db.insert(schema.trailCollaborators).values({
    trailId,
    userId: collaborator.id,
    inviterId: owner.id,
    status: 'accepted',
  })
  return trailId
}

test('members can post; response carries the hydrated post', async () => {
  const trailId = await createTrail()

  const owned = await request(
    owner,
    'POST',
    `/trails/${trailId}/posts`,
    {text: 'First post'},
  )
  expect(owned.status).toBe(200)
  expect(owned.json.post.id).toBeString()
  expect(owned.json.post.text).toBe('First post')
  expect(owned.json.post.facets).toEqual([])
  expect(owned.json.post.author).toMatchObject({
    id: owner.id,
    handle: owner.handle,
  })
  expect(owned.json.post.createdAt).toBeString()

  const shared = await request(
    collaborator,
    'POST',
    `/trails/${trailId}/posts`,
    {text: 'Also here'},
  )
  expect(shared.status).toBe(200)
})

test("rejects a client id that belongs to another member's post", async () => {
  const trailId = await createTrail()
  const id = randomUUID()
  const first = await request(
    owner,
    'POST',
    `/trails/${trailId}/posts`,
    {id, text: 'Mine'},
  )
  expect(first.status).toBe(200)
  // a fellow member reusing the id is a forgery, not a replay — rejected,
  // and the existing post is never returned to them
  const forged = await request(
    collaborator,
    'POST',
    `/trails/${trailId}/posts`,
    {id, text: 'Forged'},
  )
  expect(forged.status).toBe(409)
})

test('a post with a client id is idempotent under replay', async () => {
  const trailId = await createTrail()
  const id = randomUUID()
  const body = {id, text: 'Posted offline'}
  // the same create replayed (its ack lost to a dropped connection)
  const first = await request(
    owner,
    'POST',
    `/trails/${trailId}/posts`,
    body,
  )
  const second = await request(
    owner,
    'POST',
    `/trails/${trailId}/posts`,
    body,
  )
  expect(first.json.post.id).toBe(id)
  expect(second.json.post.id).toBe(id)
  // upserts on the pk instead of doubling
  const rows = await db
    .select({id: schema.trailPosts.id})
    .from(schema.trailPosts)
    .where(eq(schema.trailPosts.id, id))
  expect(rows).toHaveLength(1)
})

test('non-members cannot post', async () => {
  const trailId = await createTrail()

  const denied = await request(
    stranger,
    'POST',
    `/trails/${trailId}/posts`,
    {text: 'Hi'},
  )
  expect(denied.status).toBe(404)

  await db
    .update(schema.trailCollaborators)
    .set({status: 'pending'})
    .where(inArray(schema.trailCollaborators.trailId, [trailId]))
  const pending = await request(
    collaborator,
    'POST',
    `/trails/${trailId}/posts`,
    {text: 'Hi'},
  )
  expect(pending.status).toBe(404)
})

test('validates and trims post text', async () => {
  const trailId = await createTrail()

  const empty = await request(
    owner,
    'POST',
    `/trails/${trailId}/posts`,
    {text: '   '},
  )
  expect(empty.status).toBe(400)

  const long = await request(
    owner,
    'POST',
    `/trails/${trailId}/posts`,
    {text: 'x'.repeat(301)},
  )
  expect(long.status).toBe(400)

  const padded = await request(
    owner,
    'POST',
    `/trails/${trailId}/posts`,
    {text: '  trimmed  '},
  )
  expect(padded.json.post.text).toBe('trimmed')
})

test('resolves member mentions into facets, drops the rest', async () => {
  const trailId = await createTrail()

  const mixedCase =
    collaborator.handle.charAt(0).toUpperCase() +
    collaborator.handle.slice(1)
  const posted = await request(
    owner,
    'POST',
    `/trails/${trailId}/posts`,
    {text: `hey @${mixedCase} and @not-a-member`},
  )
  expect(posted.status).toBe(200)
  expect(posted.json.post.facets).toEqual([
    {
      start: 4,
      end: 5 + collaborator.handle.length,
      userId: collaborator.id,
    },
  ])
})

test('trail detail embeds the latest posts newest first', async () => {
  const trailId = await createTrail()

  for (const text of ['one', 'two', 'three', 'four']) {
    const res = await request(
      owner,
      'POST',
      `/trails/${trailId}/posts`,
      {text},
    )
    expect(res.status).toBe(200)
  }

  const detail = await request(owner, 'GET', `/trails/${trailId}`)
  expect(detail.status).toBe(200)
  const texts = detail.json.trail.posts.map(
    (p: {text: string}) => p.text,
  )
  expect(texts).toEqual(['four', 'three', 'two'])
  expect(detail.json.trail.posts[0].author.handle).toBe(owner.handle)
  // the owner's avatar is exposed on the summary for mention autocomplete
  expect(detail.json.trail.authorAvatarUrl).toBeNull()
})

test('posts paginate by cursor', async () => {
  const trailId = await createTrail()

  for (let i = 0; i < 5; i++) {
    await request(owner, 'POST', `/trails/${trailId}/posts`, {
      text: `post ${i}`,
    })
  }

  const first = await request(
    owner,
    'GET',
    `/trails/${trailId}/posts?limit=3`,
  )
  expect(first.status).toBe(200)
  expect(first.json.posts).toHaveLength(3)
  expect(first.json.nextCursor).toBeString()

  const second = await request(
    owner,
    'GET',
    `/trails/${trailId}/posts?limit=3&cursor=${first.json.nextCursor}`,
  )
  expect(second.json.posts).toHaveLength(2)
  expect(second.json.nextCursor).toBeNull()

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

  // mirrors trail detail visibility: non-friend strangers are rejected
  const denied = await request(
    stranger,
    'GET',
    `/trails/${trailId}/posts`,
  )
  expect(denied.status).toBe(403)
})

test('authors delete their own posts, owners delete any', async () => {
  const trailId = await createTrail()

  const ownPost = await request(
    collaborator,
    'POST',
    `/trails/${trailId}/posts`,
    {text: 'mine'},
  )
  const ownerPost = await request(
    owner,
    'POST',
    `/trails/${trailId}/posts`,
    {text: 'theirs'},
  )

  const notYours = await request(
    collaborator,
    'DELETE',
    `/trails/${trailId}/posts/${ownerPost.json.post.id}`,
  )
  expect(notYours.status).toBe(403)

  const own = await request(
    collaborator,
    'DELETE',
    `/trails/${trailId}/posts/${ownPost.json.post.id}`,
  )
  expect(own.status).toBe(200)

  const byOwner = await request(
    owner,
    'DELETE',
    `/trails/${trailId}/posts/${ownerPost.json.post.id}`,
  )
  expect(byOwner.status).toBe(200)

  const gone = await request(
    owner,
    'DELETE',
    `/trails/${trailId}/posts/${ownerPost.json.post.id}`,
  )
  expect(gone.status).toBe(404)

  const detail = await request(owner, 'GET', `/trails/${trailId}`)
  expect(detail.json.trail.posts).toEqual([])
})

test('posts from others count as unseen until the trail is viewed', async () => {
  const trailId = await createTrail()

  // establish a lastViewedAt for the owner
  await request(owner, 'GET', `/trails/${trailId}`)
  await request(collaborator, 'POST', `/trails/${trailId}/posts`, {
    text: 'unseen by owner',
  })

  const listed = await request(owner, 'GET', '/trails')
  const summary = listed.json.trails.find(
    (t: {id: string}) => t.id === trailId,
  )
  expect(summary.unseenCount).toBeGreaterThanOrEqual(1)

  await request(owner, 'GET', `/trails/${trailId}`)
  const after = await request(owner, 'GET', '/trails')
  const seen = after.json.trails.find(
    (t: {id: string}) => t.id === trailId,
  )
  expect(seen.unseenCount).toBe(0)

  // the author's own post is never unseen for them
  const collaboratorList = await request(
    collaborator,
    'GET',
    '/trails',
  )
  const own = collaboratorList.json.trails.find(
    (t: {id: string}) => t.id === trailId,
  )
  expect(own.unseenCount).toBe(0)
})
