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

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

const createdPlaceIds: string[] = []

// tests live on an isolated lat band; 0.01° of latitude ≈ 1.11 km
const CENTER = {lat: 45, lng: 7}

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

type PlaceRef = ReturnType<typeof placeRefAt>

const crumbAt = async (
  as: TestUser,
  ref: PlaceRef,
  status: 'saved' | 'planned' | 'done' = 'saved',
): Promise<{placeId: string; crumbId: string}> => {
  const created = await request(as, 'POST', '/crumbs', {
    name: ref.name,
    placeRef: ref,
  })
  expect(created.status).toBe(200)
  const crumbId = created.json.crumb.id as string
  const placeId = created.json.crumb.place.id as string
  if (!createdPlaceIds.includes(placeId))
    createdPlaceIds.push(placeId)
  if (status !== 'saved') {
    const patched = await request(as, 'PATCH', `/crumbs/${crumbId}`, {
      status,
    })
    expect(patched.status).toBe(200)
  }
  return {placeId, crumbId}
}

// reactions require a trail; the trail cascades from its author on cleanup
const createTrail = async (owner: TestUser): Promise<string> => {
  const [trail] = await db
    .insert(schema.trails)
    .values({authorId: owner.id, name: 'Rec trail'})
    .returning({id: schema.trails.id})
  return trail!.id
}

const react = (
  trailId: string,
  crumbId: string,
  as: TestUser,
  emoji: string,
) =>
  db.insert(schema.crumbReactions).values({
    trailId,
    crumbId,
    userId: as.id,
    emoji,
  })

type RecommendedCrumb = {
  crumb: {
    id: string
    name: string
    status: string
    authorId: string
    place: {id: string} | null
  }
  author: {id: string; handle: string}
  reactions: {emoji: string; count: number; mine: boolean}[]
  distanceKm: number
}

const recommendations = async (as: TestUser, extra = '') => {
  const res = await request(
    as,
    'GET',
    `/recommendations/crumbs?lat=${CENTER.lat}&lng=${CENTER.lng}${extra}`,
  )
  expect(res.status).toBe(200)
  return res.json.crumbs as RecommendedCrumb[]
}

const placeIdOf = (rec: RecommendedCrumb) => rec.crumb.place!.id

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

test('recommends accepted friends places only', async () => {
  const [viewer, friend, pending, stranger] = await Promise.all([
    createUser(),
    createUser(),
    createUser(),
    createUser(),
  ])
  await befriend(viewer, friend)
  await requestFriend(pending, viewer)

  const {placeId: friendPlace} = await crumbAt(
    friend,
    placeRefAt(CENTER.lat + 0.01, CENTER.lng),
  )
  const {placeId: pendingPlace} = await crumbAt(
    pending,
    placeRefAt(CENTER.lat + 0.02, CENTER.lng),
  )
  const {placeId: strangerPlace} = await crumbAt(
    stranger,
    placeRefAt(CENTER.lat + 0.03, CENTER.lng),
  )

  const recs = await recommendations(viewer)
  const ids = recs.map(placeIdOf)
  expect(ids).toContain(friendPlace)
  expect(ids).not.toContain(pendingPlace)
  expect(ids).not.toContain(strangerPlace)
})

test('excludes places the viewer has done, keeps ones merely saved', async () => {
  const [viewer, friend] = await Promise.all([
    createUser(),
    createUser(),
  ])
  await befriend(viewer, friend)

  const refA = placeRefAt(CENTER.lat + 0.01, CENTER.lng)
  const refB = placeRefAt(CENTER.lat - 0.01, CENTER.lng)
  const {placeId: placeA} = await crumbAt(friend, refA)
  const {placeId: placeB} = await crumbAt(friend, refB)
  await crumbAt(viewer, refA, 'done')
  await crumbAt(viewer, refB, 'saved')

  const recs = await recommendations(viewer)
  const ids = recs.map(placeIdOf)
  expect(ids).not.toContain(placeA)
  expect(ids).toContain(placeB)

  const b = recs.find((r) => placeIdOf(r) === placeB)!
  expect(b.author.handle).toBe(friend.handle)
})

test('a done crumb outranks a saved one at equal distance', async () => {
  const [viewer, friend] = await Promise.all([
    createUser(),
    createUser(),
  ])
  await befriend(viewer, friend)

  const {placeId: savedPlace} = await crumbAt(
    friend,
    placeRefAt(CENTER.lat + 0.01, CENTER.lng),
    'saved',
  )
  const {placeId: donePlace} = await crumbAt(
    friend,
    placeRefAt(CENTER.lat - 0.01, CENTER.lng),
    'done',
  )

  const recs = await recommendations(viewer)
  const ids = recs.map(placeIdOf)
  expect(ids.indexOf(donePlace)).toBeLessThan(ids.indexOf(savedPlace))

  const done = recs.find((r) => placeIdOf(r) === donePlace)!
  expect(done.crumb.status).toBe('done')
})

test('two friends outweigh one friend repeating themselves', async () => {
  const [viewer, f1, f2] = await Promise.all([
    createUser(),
    createUser(),
    createUser(),
  ])
  await Promise.all([befriend(viewer, f1), befriend(viewer, f2)])

  const refTwoFriends = placeRefAt(CENTER.lat + 0.01, CENTER.lng)
  const refSpammed = placeRefAt(CENTER.lat - 0.01, CENTER.lng)
  const {placeId: twoFriendsPlace} = await crumbAt(f1, refTwoFriends)
  await crumbAt(f2, refTwoFriends)
  const {placeId: spammedPlace} = await crumbAt(f1, refSpammed)
  await crumbAt(f1, refSpammed)
  await crumbAt(f1, refSpammed)

  const recs = await recommendations(viewer)
  const ids = recs.map(placeIdOf)
  expect(ids.indexOf(twoFriendsPlace)).toBeLessThan(
    ids.indexOf(spammedPlace),
  )

  // one friend repeating still yields a single card for the place
  const spammed = recs.filter((r) => placeIdOf(r) === spammedPlace)
  expect(spammed).toHaveLength(1)
})

test('nearer places rank higher on identical signal', async () => {
  const [viewer, friend] = await Promise.all([
    createUser(),
    createUser(),
  ])
  await befriend(viewer, friend)

  const {placeId: nearPlace} = await crumbAt(
    friend,
    placeRefAt(CENTER.lat + 0.01, CENTER.lng),
  )
  const {placeId: farPlace} = await crumbAt(
    friend,
    placeRefAt(CENTER.lat + 0.045, CENTER.lng),
  )

  const recs = await recommendations(viewer)
  const ids = recs.map(placeIdOf)
  expect(ids.indexOf(nearPlace)).toBeLessThan(ids.indexOf(farPlace))

  const near = recs.find((r) => placeIdOf(r) === nearPlace)!
  const far = recs.find((r) => placeIdOf(r) === farPlace)!
  expect(near.distanceKm).toBeGreaterThan(0.5)
  expect(near.distanceKm).toBeLessThan(2)
  expect(far.distanceKm).toBeGreaterThan(3)
  expect(far.distanceKm).toBeLessThan(7)
})

test('dedupes a place to its single strongest crumb', async () => {
  const [viewer, doneFriend, savedFriend] = await Promise.all([
    createUser(),
    createUser(),
    createUser(),
  ])
  await Promise.all([
    befriend(viewer, doneFriend),
    befriend(viewer, savedFriend),
  ])

  const ref = placeRefAt(CENTER.lat + 0.01, CENTER.lng)
  const {placeId} = await crumbAt(doneFriend, ref, 'done')
  await crumbAt(savedFriend, ref, 'saved')

  const recs = await recommendations(viewer)
  const cards = recs.filter((r) => placeIdOf(r) === placeId)
  expect(cards).toHaveLength(1)

  // no reactions, so the strongest status + recency signal represents it
  const [card] = cards
  expect(card!.crumb.status).toBe('done')
  expect(card!.author.handle).toBe(doneFriend.handle)
})

test('the most-reacted crumb represents its place', async () => {
  const [viewer, savedFriend, doneFriend] = await Promise.all([
    createUser(),
    createUser(),
    createUser(),
  ])
  await Promise.all([
    befriend(viewer, savedFriend),
    befriend(viewer, doneFriend),
  ])

  const ref = placeRefAt(CENTER.lat + 0.01, CENTER.lng)
  const {placeId, crumbId: savedCrumb} = await crumbAt(
    savedFriend,
    ref,
    'saved',
  )
  await crumbAt(doneFriend, ref, 'done')

  // the saved crumb is weaker by status, but reactions win it the card
  const trailId = await createTrail(viewer)
  await react(trailId, savedCrumb, viewer, '🔥')
  await react(trailId, savedCrumb, doneFriend, '🔥')

  const recs = await recommendations(viewer)
  const cards = recs.filter((r) => placeIdOf(r) === placeId)
  expect(cards).toHaveLength(1)

  const [card] = cards
  expect(card!.crumb.id).toBe(savedCrumb)
  expect(card!.crumb.status).toBe('saved')
  expect(card!.author.handle).toBe(savedFriend.handle)
  expect(card!.reactions).toEqual([
    {emoji: '🔥', count: 2, mine: true},
  ])
})

test('follows merged places to their canonical winner', async () => {
  const [viewer, friend] = await Promise.all([
    createUser(),
    createUser(),
  ])
  await befriend(viewer, friend)

  const [winner] = await db
    .insert(schema.places)
    .values({
      name: 'Rec winner',
      latitude: CENTER.lat + 0.01,
      longitude: CENTER.lng,
      status: 'canonical',
    })
    .returning({id: schema.places.id})
  const [loser] = await db
    .insert(schema.places)
    .values({
      name: 'Rec loser',
      latitude: CENTER.lat + 0.011,
      longitude: CENTER.lng,
      status: 'merged',
      mergedIntoId: winner!.id,
    })
    .returning({id: schema.places.id})
  createdPlaceIds.push(loser!.id, winner!.id)
  await db.insert(schema.crumbs).values({
    authorId: friend.id,
    name: 'crumb on merged place',
    placeId: loser!.id,
  })

  const recs = await recommendations(viewer)
  // the merged place's crumb surfaces once, ranked under its canonical winner
  expect(recs).toHaveLength(1)
  expect(recs[0]!.crumb.name).toBe('crumb on merged place')
})

test('respects the radius', async () => {
  const [viewer, friend] = await Promise.all([
    createUser(),
    createUser(),
  ])
  await befriend(viewer, friend)

  // ~7.8 km north: inside the 10 km default, outside 5 km
  const {placeId} = await crumbAt(
    friend,
    placeRefAt(CENTER.lat + 0.07, CENTER.lng),
  )

  const byDefault = await recommendations(viewer)
  expect(byDefault.map(placeIdOf)).toContain(placeId)

  const tight = await recommendations(viewer, '&radiusKm=5')
  expect(tight.map(placeIdOf)).not.toContain(placeId)
})

test('returns an empty list for a friendless viewer', async () => {
  const viewer = await createUser()
  const recs = await recommendations(viewer)
  expect(recs).toEqual([])
})

test('rejects a request without coordinates', async () => {
  const viewer = await createUser()
  const res = await request(
    viewer,
    'GET',
    `/recommendations/crumbs?lng=${CENTER.lng}`,
  )
  expect(res.status).toBe(400)
})
