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 {
  befriend,
  cleanupCreatedUsers,
  createUser,
  request,
  requestFriend,
  type TestUser,
} from '../test-fixtures'

const createdPlaceIds: string[] = []

const makePlaceRef = () => {
  const osmId = String(Math.floor(Math.random() * 1e12))
  return {
    id: `liq:node/${osmId}`,
    provider: 'locationiq' as const,
    osmType: 'node',
    osmId,
    name: 'Corner cafe',
    category: 'cafe',
    address: '1 Corner st',
    coordinate: {latitude: 51.5, longitude: -0.12},
  }
}

const makePlaceRefAt = (
  latitude: number,
  longitude: number,
  extra?: {name?: string; category?: string},
) => {
  const osmId = String(Math.floor(Math.random() * 1e12))
  return {
    id: `liq:node/${osmId}`,
    provider: 'locationiq' as const,
    osmType: 'node',
    osmId,
    name: extra?.name ?? 'Test place',
    category: extra?.category ?? 'cafe',
    address: '1 Test st',
    coordinate: {latitude, longitude},
  }
}

const bboxQuery = (
  bbox: string,
  extra: Record<string, string> = {},
) => {
  const params = new URLSearchParams({bbox, ...extra})
  return `/crumbs/map?${params.toString()}`
}

let alice: TestUser
let bob: TestUser

beforeAll(async () => {
  ;[alice, bob] = await Promise.all([createUser(), createUser()])
})

afterAll(async () => {
  await cleanupCreatedUsers()
  if (createdPlaceIds.length)
    await db
      .delete(schema.places)
      .where(inArray(schema.places.id, createdPlaceIds))
})

test('create with placeRef resolves a canonical place', async () => {
  const res = await request(alice, 'POST', '/crumbs', {
    name: 'Morning coffee',
    placeRef: makePlaceRef(),
    tags: ['coffee'],
  })
  expect(res.status).toBe(200)
  const {crumb} = res.json
  expect(crumb.place.status).toBe('canonical')
  expect(crumb.place.latitude).toBe(51.5)
  expect(crumb.tags).toEqual([{name: 'coffee', emoji: '☕'}])
  createdPlaceIds.push(crumb.place.id)
})

test('two users saving the same place share the entity', async () => {
  const ref = makePlaceRef()
  const [a, b] = await Promise.all([
    request(alice, 'POST', '/crumbs', {name: 'a', placeRef: ref}),
    request(bob, 'POST', '/crumbs', {name: 'b', placeRef: ref}),
  ])
  expect(a.json.crumb.place.id).toBe(b.json.crumb.place.id)
  createdPlaceIds.push(a.json.crumb.place.id)
})

test('create with placeDraft makes a provisional place', async () => {
  const res = await request(alice, 'POST', '/crumbs', {
    name: 'That place from the video',
    placeDraft: {name: 'Hidden ramen', address: 'shibuya'},
  })
  expect(res.status).toBe(200)
  const {crumb} = res.json
  expect(crumb.place.status).toBe('provisional')
  expect(crumb.place.latitude).toBeNull()
  createdPlaceIds.push(crumb.place.id)
})

test('create without a target is a plain note crumb', async () => {
  const res = await request(alice, 'POST', '/crumbs', {
    name: 'Just a thought',
    note: 'remember this',
  })
  expect(res.status).toBe(200)
  expect(res.json.crumb.place).toBeNull()
  expect(res.json.crumb.note).toBe('remember this')
})

test('rejects more than one target', async () => {
  const res = await request(alice, 'POST', '/crumbs', {
    name: 'broken',
    placeRef: makePlaceRef(),
    placeDraft: {name: 'x'},
  })
  expect(res.status).toBe(400)
})

test('a create with a client id is idempotent under replay', async () => {
  const id = randomUUID()
  const body = {id, name: 'Offline save', tags: ['coffee']}
  // the same create replayed (its ack lost to a dropped connection)
  const first = await request(alice, 'POST', '/crumbs', body)
  const second = await request(alice, 'POST', '/crumbs', body)
  expect(first.json.crumb.id).toBe(id)
  expect(second.json.crumb.id).toBe(id)
  // upserts on the pk instead of doubling
  const rows = await db
    .select({id: schema.crumbs.id})
    .from(schema.crumbs)
    .where(eq(schema.crumbs.id, id))
  expect(rows).toHaveLength(1)
  // and tags landed once, not twice
  expect(
    second.json.crumb.tags.map((t: {name: string}) => t.name),
  ).toEqual(['coffee'])
})

test('rejects a client id that belongs to another user', async () => {
  const id = randomUUID()
  const mine = await request(alice, 'POST', '/crumbs', {
    id,
    name: 'mine',
  })
  expect(mine.status).toBe(200)
  // bob replays alice's id: must never upsert into or leak her crumb
  const forged = await request(bob, 'POST', '/crumbs', {
    id,
    name: 'theirs',
  })
  expect(forged.status).toBe(409)
  const check = await request(alice, 'GET', `/crumbs/${id}`)
  expect(check.json.crumb.name).toBe('mine')
})

test('patch updates status and planned date', async () => {
  const created = await request(alice, 'POST', '/crumbs', {
    name: 'Plan me',
  })
  const id = created.json.crumb.id
  const plannedFor = new Date('2026-07-01T18:00:00Z').toISOString()

  const res = await request(alice, 'PATCH', `/crumbs/${id}`, {
    status: 'planned',
    plannedFor,
  })
  expect(res.status).toBe(200)
  expect(res.json.crumb.status).toBe('planned')
  expect(res.json.crumb.plannedFor).toBe(plannedFor)

  const done = await request(alice, 'PATCH', `/crumbs/${id}`, {
    status: 'done',
  })
  expect(done.json.crumb.status).toBe('done')
})

test('patch can re-pick the place', async () => {
  const created = await request(alice, 'POST', '/crumbs', {
    name: 'Wrong spot',
    placeDraft: {name: 'roughly here'},
  })
  const id = created.json.crumb.id
  createdPlaceIds.push(created.json.crumb.place.id)

  const res = await request(alice, 'PATCH', `/crumbs/${id}`, {
    placeRef: makePlaceRef(),
  })
  expect(res.status).toBe(200)
  expect(res.json.crumb.place.status).toBe('canonical')
  createdPlaceIds.push(res.json.crumb.place.id)
})

test('renaming a crumb renames its provisional place', async () => {
  const user = await createUser()
  const created = await request(user, 'POST', '/crumbs', {
    name: 'Old name',
    placeDraft: {name: 'Old name', latitude: 70, longitude: 70},
  })
  expect(created.json.crumb.place.status).toBe('provisional')
  createdPlaceIds.push(created.json.crumb.place.id)

  const res = await request(
    user,
    'PATCH',
    `/crumbs/${created.json.crumb.id}`,
    {name: 'New name'},
  )
  expect(res.status).toBe(200)
  expect(res.json.crumb.place.name).toBe('New name')
  expect(res.json.crumb.place.latitude).toBe(70) // coords preserved

  // the single-crumb map pin (titled by place) reflects the rename
  const map = await request(user, 'GET', bboxQuery('69,69,71,71'))
  const point = map.json.places.find(
    (p: {placeId: string}) =>
      p.placeId === created.json.crumb.place.id,
  )
  expect(point.name).toBe('New name')
})

test('renaming a crumb leaves a canonical place name intact', async () => {
  const user = await createUser()
  const created = await request(user, 'POST', '/crumbs', {
    name: 'My note',
    placeRef: makePlaceRefAt(73, 73, {name: 'Real Place'}),
  })
  expect(created.json.crumb.place.status).toBe('canonical')
  createdPlaceIds.push(created.json.crumb.place.id)

  const res = await request(
    user,
    'PATCH',
    `/crumbs/${created.json.crumb.id}`,
    {name: 'Renamed note'},
  )
  expect(res.json.crumb.name).toBe('Renamed note')
  expect(res.json.crumb.place.name).toBe('Real Place')
})

test('only the author can patch', async () => {
  const created = await request(alice, 'POST', '/crumbs', {
    name: 'Mine',
  })
  const res = await request(
    bob,
    'PATCH',
    `/crumbs/${created.json.crumb.id}`,
    {name: 'Stolen'},
  )
  expect(res.status).toBe(404)
})

test('list paginates with limit and cursor', async () => {
  const user = await createUser()
  for (const name of ['one', 'two', 'three'])
    await request(user, 'POST', '/crumbs', {name})

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

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

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

test('list filters by placeId', async () => {
  const user = await createUser()
  const refX = makePlaceRefAt(1, 1, {name: 'X'})
  const refY = makePlaceRefAt(2, 2, {name: 'Y'})
  const [a] = await Promise.all([
    request(user, 'POST', '/crumbs', {name: 'x1', placeRef: refX}),
    request(user, 'POST', '/crumbs', {name: 'x2', placeRef: refX}),
    request(user, 'POST', '/crumbs', {name: 'y1', placeRef: refY}),
  ])
  const placeX = a.json.crumb.place.id
  createdPlaceIds.push(placeX)

  const res = await request(user, 'GET', `/crumbs?placeId=${placeX}`)
  expect(res.status).toBe(200)
  expect(res.json.crumbs).toHaveLength(2)
  expect(
    res.json.crumbs.every(
      (c: {place: {id: string}}) => c.place.id === placeX,
    ),
  ).toBe(true)
})

test('at-place lists own and accepted friends crumbs with author handles', async () => {
  const [viewer, friend, pending, stranger] = await Promise.all([
    createUser(),
    createUser(),
    createUser(),
    createUser(),
  ])
  await Promise.all([
    befriend(viewer, friend),
    requestFriend(pending, viewer),
  ])

  const ref = makePlaceRefAt(3, 3, {name: 'Shared spot'})
  const refElsewhere = makePlaceRefAt(4, 4, {name: 'Elsewhere'})
  const mine = await request(viewer, 'POST', '/crumbs', {
    name: 'mine',
    placeRef: ref,
  })
  const placeId = mine.json.crumb.place.id
  createdPlaceIds.push(placeId)
  const elsewhere = await request(friend, 'POST', '/crumbs', {
    name: 'friend elsewhere',
    placeRef: refElsewhere,
  })
  createdPlaceIds.push(elsewhere.json.crumb.place.id)
  await request(friend, 'POST', '/crumbs', {
    name: 'friends',
    placeRef: ref,
  })
  await request(pending, 'POST', '/crumbs', {
    name: 'pendings',
    placeRef: ref,
  })
  await request(stranger, 'POST', '/crumbs', {
    name: 'strangers',
    placeRef: ref,
  })

  const res = await request(
    viewer,
    'GET',
    `/crumbs/at-place?placeId=${placeId}`,
  )
  expect(res.status).toBe(200)

  type Row = {name: string; authorHandle: string}
  const rows: Row[] = res.json.crumbs
  expect(rows.map((c) => c.name).sort()).toEqual(['friends', 'mine'])
  const byName = new Map(rows.map((c) => [c.name, c]))
  expect(byName.get('mine')!.authorHandle).toBe(viewer.handle)
  expect(byName.get('friends')!.authorHandle).toBe(friend.handle)
})

test('at-place paginates', async () => {
  const [viewer, friend] = await Promise.all([
    createUser(),
    createUser(),
  ])
  await befriend(viewer, friend)

  const ref = makePlaceRefAt(5, 5, {name: 'Paged spot'})
  const first = await request(viewer, 'POST', '/crumbs', {
    name: 'page a',
    placeRef: ref,
  })
  const placeId = first.json.crumb.place.id
  createdPlaceIds.push(placeId)
  await request(friend, 'POST', '/crumbs', {
    name: 'page b',
    placeRef: ref,
  })

  const page = await request(
    viewer,
    'GET',
    `/crumbs/at-place?placeId=${placeId}&limit=1`,
  )
  expect(page.json.crumbs).toHaveLength(1)
  expect(page.json.nextCursor).toBeTruthy()

  const rest = await request(
    viewer,
    'GET',
    `/crumbs/at-place?placeId=${placeId}&limit=1&cursor=${encodeURIComponent(page.json.nextCursor)}`,
  )
  expect(rest.json.crumbs).toHaveLength(1)
  expect(rest.json.nextCursor).toBeNull()
})

test('at-place requires a placeId', async () => {
  const viewer = await createUser()
  const res = await request(viewer, 'GET', '/crumbs/at-place')
  expect(res.status).toBe(400)
})

test('planned surfaces upcoming crumbs beyond the recent page', async () => {
  const user = await createUser()
  // an upcoming crumb created first, so newer crumbs push it past page 1
  const old = await request(user, 'POST', '/crumbs', {
    name: 'Old but upcoming',
  })
  const tomorrow = new Date(Date.now() + 864e5).toISOString()
  await request(user, 'PATCH', `/crumbs/${old.json.crumb.id}`, {
    status: 'planned',
    plannedFor: tomorrow,
  })
  for (let i = 0; i < 25; i++)
    await request(user, 'POST', '/crumbs', {name: `n${i}`})

  const recent = await request(user, 'GET', '/crumbs?limit=20')
  expect(
    recent.json.crumbs.some(
      (c: {id: string}) => c.id === old.json.crumb.id,
    ),
  ).toBe(false)

  const planned = await request(user, 'GET', '/crumbs/planned')
  expect(planned.status).toBe(200)
  expect(
    planned.json.crumbs.some(
      (c: {id: string}) => c.id === old.json.crumb.id,
    ),
  ).toBe(true)
})

test('planned excludes done and past crumbs, soonest first', async () => {
  const user = await createUser()
  const day = 864e5
  const plan = async (
    name: string,
    at: number,
    status = 'planned',
  ) => {
    const r = await request(user, 'POST', '/crumbs', {name})
    await request(user, 'PATCH', `/crumbs/${r.json.crumb.id}`, {
      status,
      plannedFor: new Date(at).toISOString(),
    })
    return r.json.crumb.id as string
  }
  const soonId = await plan('soon', Date.now() + day)
  const laterId = await plan('later', Date.now() + 5 * day)
  const pastId = await plan('past', Date.now() - day)
  const doneId = await plan('done', Date.now() + day, 'done')

  const res = await request(user, 'GET', '/crumbs/planned')
  const ids = res.json.crumbs.map((c: {id: string}) => c.id)
  expect(ids).toContain(soonId)
  expect(ids).toContain(laterId)
  expect(ids).not.toContain(pastId)
  expect(ids).not.toContain(doneId)
  expect(ids.indexOf(soonId)).toBeLessThan(ids.indexOf(laterId))
})

test('map aggregates the viewer places inside the bbox', async () => {
  const user = await createUser()
  const near = makePlaceRefAt(10, 20, {name: 'Near'})
  const alsoNear = makePlaceRefAt(10.1, 20.1, {name: 'Also near'})
  const far = makePlaceRefAt(-40, 100, {name: 'Far'})
  const [n, , a, f] = await Promise.all([
    request(user, 'POST', '/crumbs', {name: 'n1', placeRef: near}),
    request(user, 'POST', '/crumbs', {name: 'n2', placeRef: near}),
    request(user, 'POST', '/crumbs', {
      name: 'a1',
      placeRef: alsoNear,
    }),
    request(user, 'POST', '/crumbs', {name: 'f1', placeRef: far}),
  ])
  createdPlaceIds.push(
    n.json.crumb.place.id,
    a.json.crumb.place.id,
    f.json.crumb.place.id,
  )

  const res = await request(user, 'GET', bboxQuery('19,9,21,11'))
  expect(res.status).toBe(200)
  expect(res.json.truncated).toBe(false)
  const names = res.json.places.map((p: {name: string}) => p.name)
  expect(names).toEqual(['Near', 'Also near']) // densest first
  expect(res.json.places[0].crumbCount).toBe(2)
  expect(res.json.places[1].crumbCount).toBe(1)
})

test('map returns the crumb id for a single-crumb place', async () => {
  const user = await createUser()
  const ref = makePlaceRefAt(60, 60, {name: 'Solo'})
  const created = await request(user, 'POST', '/crumbs', {
    name: 'only',
    placeRef: ref,
  })
  createdPlaceIds.push(created.json.crumb.place.id)

  const res = await request(user, 'GET', bboxQuery('59,59,61,61'))
  const point = res.json.places.find(
    (p: {placeId: string}) =>
      p.placeId === created.json.crumb.place.id,
  )
  expect(point.crumbCount).toBe(1)
  expect(point.crumbId).toBe(created.json.crumb.id)
})

test('map is author-scoped', async () => {
  const ref = makePlaceRefAt(30, 30, {name: 'Mine'})
  const mine = await request(alice, 'POST', '/crumbs', {
    name: 'secret',
    placeRef: ref,
  })
  createdPlaceIds.push(mine.json.crumb.place.id)

  const res = await request(bob, 'GET', bboxQuery('29,29,31,31'))
  expect(res.status).toBe(200)
  expect(
    res.json.places.some((p: {name: string}) => p.name === 'Mine'),
  ).toBe(false)
})

test('map filters by status', async () => {
  const user = await createUser()
  const ref = makePlaceRefAt(40, 40, {name: 'Planned spot'})
  const created = await request(user, 'POST', '/crumbs', {
    name: 'visit',
    placeRef: ref,
  })
  createdPlaceIds.push(created.json.crumb.place.id)
  await request(user, 'PATCH', `/crumbs/${created.json.crumb.id}`, {
    status: 'done',
  })

  const done = await request(
    user,
    'GET',
    bboxQuery('39,39,41,41', {status: 'done'}),
  )
  expect(done.json.places).toHaveLength(1)
  expect(done.json.places[0].crumbCount).toBe(1)

  const planned = await request(
    user,
    'GET',
    bboxQuery('39,39,41,41', {status: 'planned'}),
  )
  expect(planned.json.places).toHaveLength(0)
})

test('map filters by tag', async () => {
  const user = await createUser()
  const ref = makePlaceRefAt(50, 50, {name: 'Cafe'})
  const created = await request(user, 'POST', '/crumbs', {
    name: 'latte',
    placeRef: ref,
    tags: ['coffee'],
  })
  createdPlaceIds.push(created.json.crumb.place.id)
  const other = await request(user, 'POST', '/crumbs', {
    name: 'plain',
    placeRef: makePlaceRefAt(50.1, 50.1, {name: 'Other'}),
  })
  createdPlaceIds.push(other.json.crumb.place.id)

  const res = await request(
    user,
    'GET',
    bboxQuery('49,49,51,51', {tag: 'coffee'}),
  )
  expect(res.json.places).toHaveLength(1)
  expect(res.json.places[0].name).toBe('Cafe')
})

test('map rejects an invalid bbox', async () => {
  const res = await request(alice, 'GET', bboxQuery('1,2,3'))
  expect(res.status).toBe(400)
})

test('crumb detail carries author and viewer-visible trails', async () => {
  const author = await createUser()
  const viewer = await createUser()
  const outsider = await createUser()
  await befriend(author, viewer)
  await befriend(author, outsider)

  const created = await request(author, 'POST', '/crumbs', {
    name: 'Detail spot',
  })
  const crumbId = created.json.crumb.id

  // the author's own trail contains the crumb
  const ownTrail = await request(author, 'POST', '/trails', {
    name: 'Own trail',
    crumbId,
  })
  const ownTrailId = ownTrail.json.trail.id

  // the crumb is also in an outsider-owned trail the author collaborates
  // on — invisible to the viewer, who is no friend of that trail's author
  const outsiderTrail = await request(outsider, 'POST', '/trails', {
    name: 'Outsider trail',
  })
  const outsiderTrailId = outsiderTrail.json.trail.id
  await db.insert(schema.trailCollaborators).values({
    trailId: outsiderTrailId,
    userId: author.id,
    inviterId: outsider.id,
    status: 'accepted',
  })
  const added = await request(
    author,
    'POST',
    `/trails/${outsiderTrailId}/crumbs`,
    {crumbId},
  )
  expect(added.status).toBe(200)

  const asViewer = await request(viewer, 'GET', `/crumbs/${crumbId}`)
  expect(asViewer.status).toBe(200)
  expect(asViewer.json.author).toMatchObject({
    id: author.id,
    handle: author.handle,
  })
  expect(asViewer.json.trails.map((t: {id: string}) => t.id)).toEqual(
    [ownTrailId],
  )

  // the author, a member of both trails, sees both
  const asAuthor = await request(author, 'GET', `/crumbs/${crumbId}`)
  expect(
    asAuthor.json.trails.map((t: {id: string}) => t.id).sort(),
  ).toEqual([ownTrailId, outsiderTrailId].sort())
})

test("patching someone else's crumb creates no place rows", async () => {
  const created = await request(alice, 'POST', '/crumbs', {
    name: 'mine',
  })
  const id = created.json.crumb.id

  const placeName = `intruder-place-${id}`
  const res = await request(bob, 'PATCH', `/crumbs/${id}`, {
    placeDraft: {name: placeName},
  })
  expect(res.status).toBe(404)

  const rows = await db
    .select({id: schema.places.id})
    .from(schema.places)
    .where(eq(schema.places.name, placeName))
  expect(rows).toHaveLength(0)
})
