import {randomUUID} from 'node:crypto'

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

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

const invite = (as: TestUser, trailId: string, invitee: TestUser) =>
  request(as, 'POST', `/trails/${trailId}/invites`, {
    inviteeId: invitee.id,
  })

const acceptInvite = async (as: TestUser, trailId: string) => {
  const invites = await request(as, 'GET', '/trails/invites')
  const pending = invites.json.invites.find(
    (i: {trail: {id: string}}) => i.trail.id === trailId,
  )
  expect(pending).toBeDefined()
  const res = await request(
    as,
    'POST',
    `/trails/invites/${pending.id}/accept`,
  )
  expect(res.status).toBe(200)
}

// owner <-> friend are friends; collabFriend is only friends with
// friend; stranger knows nobody
let owner: TestUser
let friend: TestUser
let collabFriend: TestUser
let stranger: TestUser

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

afterAll(cleanupCreatedUsers)

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

test('rejects a trail client id that belongs to another user', async () => {
  const id = randomUUID()
  const mine = await request(owner, 'POST', '/trails', {
    id,
    name: 'mine',
  })
  expect(mine.status).toBe(200)
  // stranger replays owner's id: must never upsert into or leak the trail
  const forged = await request(stranger, 'POST', '/trails', {
    id,
    name: 'theirs',
  })
  expect(forged.status).toBe(409)
})

test('invite lifecycle: invite, decline, re-invite, accept', async () => {
  const crumbId = await createCrumb(owner)
  const trailId = await createTrail(owner, {crumbId})

  expect((await invite(owner, trailId, friend)).status).toBe(200)

  const invites = await request(friend, 'GET', '/trails/invites')
  const pending = invites.json.invites.find(
    (i: {trail: {id: string}}) => i.trail.id === trailId,
  )
  expect(pending.trail.name).toBe('Test trail')
  expect(pending.trail.crumbCount).toBe(1)
  expect(pending.trail.memberCount).toBe(1)
  expect(pending.inviter.handle).toBe(owner.handle)

  const declined = await request(
    friend,
    'POST',
    `/trails/invites/${pending.id}/decline`,
  )
  expect(declined.status).toBe(200)
  const afterDecline = await request(friend, 'GET', '/trails/invites')
  expect(
    afterDecline.json.invites.some(
      (i: {id: string}) => i.id === pending.id,
    ),
  ).toBe(false)

  // decline deletes the row, so re-inviting works
  expect((await invite(owner, trailId, friend)).status).toBe(200)
  await acceptInvite(friend, trailId)

  // accepted invite no longer pending
  const afterAccept = await request(friend, 'GET', '/trails/invites')
  expect(
    afterAccept.json.invites.some(
      (i: {trail: {id: string}}) => i.trail.id === trailId,
    ),
  ).toBe(false)
})

test('invite rules: friends only, no self, no duplicates, members only', async () => {
  const trailId = await createTrail(owner)

  expect((await invite(owner, trailId, stranger)).status).toBe(403)
  expect((await invite(owner, trailId, owner)).status).toBe(400)

  expect((await invite(owner, trailId, friend)).status).toBe(200)
  expect((await invite(owner, trailId, friend)).status).toBe(409)

  await acceptInvite(friend, trailId)
  expect((await invite(owner, trailId, friend)).status).toBe(409)
  // inviting the owner back is rejected
  expect((await invite(friend, trailId, owner)).status).toBe(409)

  // non-members cannot invite, even their own friends
  expect((await invite(stranger, trailId, owner)).status).toBe(404)
})

test('only the invitee can accept or decline', async () => {
  const trailId = await createTrail(owner)
  await invite(owner, trailId, friend)

  const invites = await request(friend, 'GET', '/trails/invites')
  const pending = invites.json.invites.find(
    (i: {trail: {id: string}}) => i.trail.id === trailId,
  )

  expect(
    (
      await request(
        stranger,
        'POST',
        `/trails/invites/${pending.id}/accept`,
      )
    ).status,
  ).toBe(404)
  expect(
    (
      await request(
        stranger,
        'POST',
        `/trails/invites/${pending.id}/decline`,
      )
    ).status,
  ).toBe(404)
})

test('accepted collaborator sees the trail like their own', async () => {
  const crumbId = await createCrumb(owner)
  const trailId = await createTrail(owner, {crumbId})
  await invite(owner, trailId, friend)
  await acceptInvite(friend, trailId)

  const list = await request(friend, 'GET', '/trails')
  const shared = list.json.trails.find(
    (t: {id: string}) => t.id === trailId,
  )
  expect(shared).toBeDefined()
  expect(shared.authorHandle).toBe(owner.handle)

  const detail = await request(friend, 'GET', `/trails/${trailId}`)
  expect(detail.status).toBe(200)
  expect(detail.json.trail.viewerRole).toBe('collaborator')
  expect(
    detail.json.trail.collaborators.map((p: {id: string}) => p.id),
  ).toContain(friend.id)

  // owner sees their role and the collaborator list too
  const ownerDetail = await request(
    owner,
    'GET',
    `/trails/${trailId}`,
  )
  expect(ownerDetail.json.trail.viewerRole).toBe('owner')
  expect(
    ownerDetail.json.trail.collaborators.map(
      (p: {id: string}) => p.id,
    ),
  ).toContain(friend.id)
})

test('friend of author views without membership, stranger is blocked', async () => {
  const trailId = await createTrail(owner)

  const friendView = await request(
    friend,
    'GET',
    `/trails/${trailId}`,
  )
  expect(friendView.status).toBe(200)
  expect(friendView.json.trail.viewerRole).toBe('viewer')

  expect(
    (await request(stranger, 'GET', `/trails/${trailId}`)).status,
  ).toBe(403)
  expect(
    (await request(stranger, 'GET', '/trails')).json.trails.some(
      (t: {id: string}) => t.id === trailId,
    ),
  ).toBe(false)
})

test('crumb permissions: members file any viewable crumb; filer or owner removes', async () => {
  const ownerCrumb = await createCrumb(owner, 'owner spot')
  const trailId = await createTrail(owner, {crumbId: ownerCrumb})
  await invite(owner, trailId, friend)
  await acceptInvite(friend, trailId)

  // a collaborator files their own crumb
  const friendCrumb = await createCrumb(friend, 'friend spot')
  expect(
    (
      await request(friend, 'POST', `/trails/${trailId}/crumbs`, {
        crumbId: friendCrumb,
      })
    ).status,
  ).toBe(200)

  // and may file another member's crumb they can see (they are friends)
  const anotherOwnerCrumb = await createCrumb(owner, 'owner two')
  expect(
    (
      await request(friend, 'POST', `/trails/${trailId}/crumbs`, {
        crumbId: anotherOwnerCrumb,
      })
    ).status,
  ).toBe(200)

  // but not a stranger's crumb they cannot see
  const strangerCrumb = await createCrumb(stranger, 'secret')
  expect(
    (
      await request(friend, 'POST', `/trails/${trailId}/crumbs`, {
        crumbId: strangerCrumb,
      })
    ).status,
  ).toBe(403)

  // removal keys on who filed it, not who authored it: the collaborator
  // cannot remove the owner-filed crumb
  expect(
    (
      await request(
        friend,
        'DELETE',
        `/trails/${trailId}/crumbs/${ownerCrumb}`,
      )
    ).status,
  ).toBe(403)

  // but can remove a crumb they filed, even one the owner authored
  expect(
    (
      await request(
        friend,
        'DELETE',
        `/trails/${trailId}/crumbs/${anotherOwnerCrumb}`,
      )
    ).status,
  ).toBe(200)

  // the owner removes any crumb
  expect(
    (
      await request(
        owner,
        'DELETE',
        `/trails/${trailId}/crumbs/${friendCrumb}`,
      )
    ).status,
  ).toBe(200)

  // only the owner can delete the trail itself
  expect(
    (await request(friend, 'DELETE', `/trails/${trailId}`)).status,
  ).toBe(404)
})

test('a friend’s crumb can be filed into your own trail', async () => {
  const friendCrumb = await createCrumb(friend, 'friend place')
  // owner's own trail; friend is not a member of it
  const trailId = await createTrail(owner)
  const added = await request(
    owner,
    'POST',
    `/trails/${trailId}/crumbs`,
    {
      crumbId: friendCrumb,
    },
  )
  expect(added.status).toBe(200)

  const detail = await request(owner, 'GET', `/trails/${trailId}`)
  expect(
    detail.json.trail.crumbs.map((cr: {id: string}) => cr.id),
  ).toContain(friendCrumb)
})

test('adding a crumb already in the trail answers 409 already_in_trail', async () => {
  const crumbId = await createCrumb(owner)
  const trailId = await createTrail(owner, {crumbId})
  // the client's replayed add relies on this exact verdict to resolve as
  // already-done; anything else would retry into a dead-letter
  const dupe = await app.request(`/trails/${trailId}/crumbs`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${owner.token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({crumbId}),
  })
  expect(dupe.status).toBe(409)
  expect(await dupe.text()).toBe('already_in_trail')
})

test('collaborator can invite their own friends into the trail', async () => {
  const ownerCrumb = await createCrumb(owner)
  const trailId = await createTrail(owner, {crumbId: ownerCrumb})
  await invite(owner, trailId, friend)
  await acceptInvite(friend, trailId)

  // collabFriend is not friends with owner, but is with friend
  expect((await invite(friend, trailId, collabFriend)).status).toBe(
    200,
  )
  await acceptInvite(collabFriend, trailId)

  const detail = await request(
    collabFriend,
    'GET',
    `/trails/${trailId}`,
  )
  expect(detail.json.trail.viewerRole).toBe('collaborator')

  // shared trail makes the owner's crumb visible to a
  // co-collaborator who is not the owner's friend
  expect(
    (await request(collabFriend, 'GET', `/crumbs/${ownerCrumb}`))
      .status,
  ).toBe(200)
  expect(
    (await request(stranger, 'GET', `/crumbs/${ownerCrumb}`)).status,
  ).toBe(403)
})

test('leaving a trail removes access', async () => {
  const trailId = await createTrail(owner)
  await invite(owner, trailId, friend)
  await acceptInvite(friend, trailId)

  expect(
    (
      await request(
        friend,
        'DELETE',
        `/trails/${trailId}/collaborators/me`,
      )
    ).status,
  ).toBe(200)

  expect(
    (await request(friend, 'GET', '/trails')).json.trails.some(
      (t: {id: string}) => t.id === trailId,
    ),
  ).toBe(false)
  expect(
    (await request(friend, 'GET', `/trails/${trailId}`)).status,
  ).toBe(200) // still a friend of the author, so plain viewer access

  // no membership row left to delete; owner never has one
  expect(
    (
      await request(
        friend,
        'DELETE',
        `/trails/${trailId}/collaborators/me`,
      )
    ).status,
  ).toBe(404)
  expect(
    (
      await request(
        owner,
        'DELETE',
        `/trails/${trailId}/collaborators/me`,
      )
    ).status,
  ).toBe(404)
})

test('list paginates with limit and cursor', async () => {
  const user = await createUser()
  await Promise.all([
    createTrail(user),
    createTrail(user),
    createTrail(user),
  ])

  const first = await request(user, 'GET', '/trails?limit=2')
  expect(first.status).toBe(200)
  expect(first.json.trails).toHaveLength(2)
  expect(first.json.nextCursor).toBeTruthy()

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

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

test('quick surfaces pinned and shared trails only', async () => {
  const a = await createUser()
  const b = await createUser()
  await befriend(a, b)

  const ownPinned = await createTrail(a)
  expect(
    (await request(a, 'PUT', `/trails/${ownPinned}/pin`)).status,
  ).toBe(200)
  const ownPlain = await createTrail(a)

  const shared = await createTrail(b)
  await invite(b, shared, a)
  await acceptInvite(a, shared)

  const res = await request(a, 'GET', '/trails/quick')
  expect(res.status).toBe(200)
  const ids = res.json.trails.map((t: {id: string}) => t.id)
  expect(ids).toContain(ownPinned)
  expect(ids).toContain(shared)
  expect(ids).not.toContain(ownPlain)
})

test('deleting a trail cascades collaborator rows and invites', async () => {
  const trailId = await createTrail(owner)
  await invite(owner, trailId, friend)
  await acceptInvite(friend, trailId)

  expect(
    (await request(owner, 'DELETE', `/trails/${trailId}`)).status,
  ).toBe(200)

  const rows = await db
    .select()
    .from(schema.trailCollaborators)
    .where(eq(schema.trailCollaborators.trailId, trailId))
  expect(rows).toHaveLength(0)
})

test('rename: owner only, propagates to viewers', async () => {
  const trailId = await createTrail(owner)
  await invite(owner, trailId, friend)
  await acceptInvite(friend, trailId)

  const renamed = await request(
    owner,
    'PATCH',
    `/trails/${trailId}`,
    {
      name: 'Renamed trail',
    },
  )
  expect(renamed.status).toBe(200)
  // returns the updated summary so a replayed client edit can reconcile
  expect(renamed.json.trail.name).toBe('Renamed trail')

  const detail = await request(friend, 'GET', `/trails/${trailId}`)
  expect(detail.json.trail.name).toBe('Renamed trail')

  expect(
    (
      await request(friend, 'PATCH', `/trails/${trailId}`, {
        name: 'Nope',
      })
    ).status,
  ).toBe(404)
  expect(
    (
      await request(stranger, 'PATCH', `/trails/${trailId}`, {
        name: 'Nope',
      })
    ).status,
  ).toBe(404)
  expect(
    (await request(owner, 'PATCH', `/trails/${trailId}`, {name: ''}))
      .status,
  ).toBe(400)
})

test('description: owner edits, clears, propagates; others cannot', async () => {
  const trailId = await createTrail(owner)
  await invite(owner, trailId, friend)
  await acceptInvite(friend, trailId)

  const updated = await request(
    owner,
    'PATCH',
    `/trails/${trailId}`,
    {description: 'A weekend of coffee spots'},
  )
  expect(updated.status).toBe(200)

  const detail = await request(friend, 'GET', `/trails/${trailId}`)
  expect(detail.json.trail.description).toBe(
    'A weekend of coffee spots',
  )

  const list = await request(owner, 'GET', '/trails')
  const listed = list.json.trails.find(
    (t: {id: string}) => t.id === trailId,
  )
  expect(listed.description).toBe('A weekend of coffee spots')

  // renaming alone leaves the description untouched
  await request(owner, 'PATCH', `/trails/${trailId}`, {
    name: 'Renamed again',
  })
  const afterRename = await request(
    owner,
    'GET',
    `/trails/${trailId}`,
  )
  expect(afterRename.json.trail.description).toBe(
    'A weekend of coffee spots',
  )

  // an empty description clears it
  await request(owner, 'PATCH', `/trails/${trailId}`, {
    description: '',
  })
  const cleared = await request(owner, 'GET', `/trails/${trailId}`)
  expect(cleared.json.trail.description).toBeNull()

  // collaborators and strangers cannot edit
  expect(
    (
      await request(friend, 'PATCH', `/trails/${trailId}`, {
        description: 'Nope',
      })
    ).status,
  ).toBe(404)
  expect(
    (
      await request(stranger, 'PATCH', `/trails/${trailId}`, {
        description: 'Nope',
      })
    ).status,
  ).toBe(404)

  // validation: empty patch and oversize description
  expect(
    (await request(owner, 'PATCH', `/trails/${trailId}`, {})).status,
  ).toBe(400)
  expect(
    (
      await request(owner, 'PATCH', `/trails/${trailId}`, {
        description: 'x'.repeat(501),
      })
    ).status,
  ).toBe(400)
})

const moveCrumb = (
  as: TestUser,
  trailId: string,
  crumbId: string,
  position: number,
) =>
  request(
    as,
    'PUT',
    `/trails/${trailId}/crumbs/${crumbId}/position`,
    {
      position,
    },
  )

const crumbOrder = async (as: TestUser, trailId: string) => {
  const detail = await request(as, 'GET', `/trails/${trailId}`)
  expect(detail.status).toBe(200)
  return detail.json.trail.crumbs.map((c: {id: string}) => c.id)
}

test('reorder: members move crumbs, order propagates', async () => {
  const [a, b, c] = await Promise.all([
    createCrumb(owner, 'a'),
    createCrumb(owner, 'b'),
    createCrumb(owner, 'c'),
  ])
  const trailId = await createTrail(owner, {crumbId: a})
  await request(owner, 'POST', `/trails/${trailId}/crumbs`, {
    crumbId: b,
  })
  await request(owner, 'POST', `/trails/${trailId}/crumbs`, {
    crumbId: c,
  })
  expect(await crumbOrder(owner, trailId)).toEqual([a, b, c])

  // move last to front
  expect((await moveCrumb(owner, trailId, c, 0)).status).toBe(200)
  expect(await crumbOrder(owner, trailId)).toEqual([c, a, b])

  // out-of-range target clamps to the end
  expect((await moveCrumb(owner, trailId, c, 99)).status).toBe(200)
  expect(await crumbOrder(owner, trailId)).toEqual([a, b, c])

  // no-op move keeps order
  expect((await moveCrumb(owner, trailId, b, 1)).status).toBe(200)
  expect(await crumbOrder(owner, trailId)).toEqual([a, b, c])

  // collaborators reorder too, and see the same order
  await invite(owner, trailId, friend)
  await acceptInvite(friend, trailId)
  expect((await moveCrumb(friend, trailId, a, 2)).status).toBe(200)
  expect(await crumbOrder(friend, trailId)).toEqual([b, c, a])

  // adding after a reorder appends at the end
  const d = await createCrumb(owner, 'd')
  await request(owner, 'POST', `/trails/${trailId}/crumbs`, {
    crumbId: d,
  })
  expect(await crumbOrder(owner, trailId)).toEqual([b, c, a, d])
})

test('reorder permissions and validation', async () => {
  const a = await createCrumb(owner)
  const trailId = await createTrail(owner, {crumbId: a})

  // viewer (friend of author, not a member) and stranger cannot reorder
  expect((await moveCrumb(friend, trailId, a, 0)).status).toBe(404)
  expect((await moveCrumb(stranger, trailId, a, 0)).status).toBe(404)

  // crumb not in the trail
  const other = await createCrumb(owner)
  expect((await moveCrumb(owner, trailId, other, 0)).status).toBe(404)

  // invalid position
  expect((await moveCrumb(owner, trailId, a, -1)).status).toBe(400)
})

test('suggest: member trails sharing tags, best match first', async () => {
  const tag = `sug-${randomUUID().slice(0, 8)}`
  const tagged = async (as: TestUser, name: string) => {
    const res = await request(as, 'POST', '/crumbs', {
      name,
      tags: [tag],
    })
    expect(res.status).toBe(200)
    return res.json.crumb.id as string
  }

  // owner's trail holds a crumb sharing the tag with the new crumb
  const filed = await tagged(owner, 'filed')
  const trailId = await createTrail(owner, {crumbId: filed})
  const fresh = await tagged(owner, 'fresh')

  const res = await request(
    owner,
    'GET',
    `/trails/suggest?crumbId=${fresh}`,
  )
  expect(res.status).toBe(200)
  expect(res.json.suggestion?.id).toBe(trailId)

  // a crumb already in the trail is never suggested into it
  const already = await request(
    owner,
    'GET',
    `/trails/suggest?crumbId=${filed}`,
  )
  expect(already.json.suggestion).toBeNull()

  // matching trails the user is not a member of never suggest; an
  // accepted collaborator's trail does
  const strangerCrumb = await tagged(stranger, 'strangers')
  expect(
    (
      await request(
        stranger,
        'GET',
        `/trails/suggest?crumbId=${strangerCrumb}`,
      )
    ).json.suggestion,
  ).toBeNull()

  const collabCrumb = await tagged(friend, 'collabs')
  await invite(owner, trailId, friend)
  await acceptInvite(friend, trailId)
  expect(
    (
      await request(
        friend,
        'GET',
        `/trails/suggest?crumbId=${collabCrumb}`,
      )
    ).json.suggestion?.id,
  ).toBe(trailId)
})
