import {randomUUID} from 'node:crypto'
import {rm} from 'node:fs/promises'
import {join} from 'node:path'

import {afterAll, expect, test} from 'bun:test'
import sharp from 'sharp'

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

const createdTrailIds: string[] = []
const createdCrumbIds: string[] = []

const samplePng = (
  background = {r: 10, g: 120, b: 200},
): Promise<Buffer> =>
  sharp({
    create: {width: 8, height: 8, channels: 3, background},
  })
    .png()
    .toBuffer()

// track created ids so uploaded objects can be removed from disk
const createTrail = async (as: TestUser, name = 'Cover trail') => {
  const id = await createTrailFixture(as, {name})
  createdTrailIds.push(id)
  return id
}

const createCrumb = async (as: TestUser, name = 'spot') => {
  const id = await createCrumbFixture(as, name)
  createdCrumbIds.push(id)
  return id
}

const putCover = (
  token: string | null,
  id: string,
  body: Uint8Array,
) =>
  app.request(`/trails/${id}/cover`, {
    method: 'PUT',
    headers: {
      ...(token ? {Authorization: `Bearer ${token}`} : {}),
      'Content-Type': 'application/octet-stream',
    },
    body: new Blob([Uint8Array.from(body)]),
  })

const coverUrlOf = async (res: Response) =>
  ((await res.json()) as {trail: {coverImageUrl: string | null}})
    .trail.coverImageUrl

const detailCoverUrl = async (as: TestUser, trailId: string) => {
  const detail = await request(as, 'GET', `/trails/${trailId}`)
  expect(detail.status).toBe(200)
  return detail.json.trail.coverImageUrl as string | null
}

afterAll(async () => {
  await cleanupCreatedUsers()
  await Promise.all([
    ...createdTrailIds.map((id) =>
      rm(join(env.UPLOADS_DIR, 'trail-covers', id), {
        recursive: true,
        force: true,
      }),
    ),
    ...createdCrumbIds.map((id) =>
      rm(join(env.UPLOADS_DIR, 'crumb-images', id), {
        recursive: true,
        force: true,
      }),
    ),
  ])
})

test('rejects an unauthenticated upload', async () => {
  const res = await putCover(null, randomUUID(), await samplePng())
  expect(res.status).toBe(401)
})

test('rejects bytes that are not an image', async () => {
  const owner = await createUser()
  const trailId = await createTrail(owner)
  const res = await putCover(
    owner.token,
    trailId,
    new Uint8Array(64).fill(7),
  )
  expect(res.status).toBe(400)
})

test('stores the cover and surfaces it on detail and list', async () => {
  const owner = await createUser()
  const trailId = await createTrail(owner)

  const res = await putCover(owner.token, trailId, await samplePng())
  expect(res.status).toBe(200)
  const coverUrl = await coverUrlOf(res)
  expect(coverUrl).toContain(`/static/trail-covers/${trailId}/`)
  expect(coverUrl).toEndWith('.webp')

  expect(await detailCoverUrl(owner, trailId)).toBe(coverUrl)

  const list = await request(owner, 'GET', '/trails')
  const listed = list.json.trails.find(
    (t: {id: string}) => t.id === trailId,
  )
  expect(listed.coverImageUrl).toBe(coverUrl)

  const served = await app.request(new URL(coverUrl!).pathname)
  expect(served.status).toBe(200)
  expect(served.headers.get('content-type')).toContain('image/webp')
})

test('replacing the cover deletes the previous object', async () => {
  const owner = await createUser()
  const trailId = await createTrail(owner)

  const first = await putCover(
    owner.token,
    trailId,
    await samplePng({r: 1, g: 2, b: 3}),
  )
  const firstPath = new URL((await coverUrlOf(first))!).pathname
  const second = await putCover(
    owner.token,
    trailId,
    await samplePng({r: 200, g: 100, b: 50}),
  )
  const secondPath = new URL((await coverUrlOf(second))!).pathname

  expect(secondPath).not.toBe(firstPath)
  expect((await app.request(firstPath)).status).toBe(404)
  expect((await app.request(secondPath)).status).toBe(200)
})

test('deleting clears the cover and removes the object', async () => {
  const owner = await createUser()
  const trailId = await createTrail(owner)
  const put = await putCover(owner.token, trailId, await samplePng())
  const path = new URL((await coverUrlOf(put))!).pathname

  const res = await app.request(`/trails/${trailId}/cover`, {
    method: 'DELETE',
    headers: {Authorization: `Bearer ${owner.token}`},
  })
  expect(res.status).toBe(200)
  expect(await coverUrlOf(res)).toBeNull()
  expect((await app.request(path)).status).toBe(404)
})

test('only the owner can set or remove the cover', async () => {
  const owner = await createUser()
  const collaborator = await createUser()
  const trailId = await createTrail(owner)
  await db.insert(schema.trailCollaborators).values({
    trailId,
    userId: collaborator.id,
    inviterId: owner.id,
    status: 'accepted',
  })

  expect(
    (await putCover(collaborator.token, trailId, await samplePng()))
      .status,
  ).toBe(404)
  const removed = await app.request(`/trails/${trailId}/cover`, {
    method: 'DELETE',
    headers: {Authorization: `Bearer ${collaborator.token}`},
  })
  expect(removed.status).toBe(404)
})

test('falls back to the first crumb image when unset', async () => {
  const owner = await createUser()
  const bare = await createCrumb(owner, 'no image')
  const pictured = await createCrumb(owner, 'with image')
  const upload = await app.request(`/crumbs/${pictured}/image`, {
    method: 'PUT',
    headers: {
      Authorization: `Bearer ${owner.token}`,
      'Content-Type': 'application/octet-stream',
    },
    body: new Blob([Uint8Array.from(await samplePng())]),
  })
  expect(upload.status).toBe(200)

  const trailId = await createTrail(owner)
  for (const crumbId of [bare, pictured]) {
    const added = await request(
      owner,
      'POST',
      `/trails/${trailId}/crumbs`,
      {crumbId},
    )
    expect(added.status).toBe(200)
  }

  // first crumb with an image wins, skipping imageless ones before it
  const fallback = await detailCoverUrl(owner, trailId)
  expect(fallback).toContain(`/static/crumb-images/${pictured}/`)

  // an explicit cover wins over the fallback
  const put = await putCover(owner.token, trailId, await samplePng())
  const coverUrl = await coverUrlOf(put)
  expect(await detailCoverUrl(owner, trailId)).toBe(coverUrl)

  // removing the cover falls back again
  await app.request(`/trails/${trailId}/cover`, {
    method: 'DELETE',
    headers: {Authorization: `Bearer ${owner.token}`},
  })
  expect(await detailCoverUrl(owner, trailId)).toBe(fallback)
})

test('invite previews carry the description and cover', async () => {
  const owner = await createUser()
  const invitee = await createUser()
  await befriend(owner, invitee)

  const trailId = await createTrail(owner)
  await request(owner, 'PATCH', `/trails/${trailId}`, {
    description: 'Best pastries in town',
  })
  const put = await putCover(owner.token, trailId, await samplePng())
  const coverUrl = await coverUrlOf(put)

  const sent = await request(
    owner,
    'POST',
    `/trails/${trailId}/invites`,
    {inviteeId: invitee.id},
  )
  expect(sent.status).toBe(200)

  const invites = await request(invitee, 'GET', '/trails/invites')
  const pending = invites.json.invites.find(
    (i: {trail: {id: string}}) => i.trail.id === trailId,
  )
  expect(pending.trail.description).toBe('Best pastries in town')
  expect(pending.trail.coverImageUrl).toBe(coverUrl)
})

test('join landing page previews the trail to token holders', async () => {
  const owner = await createUser()
  const trailId = await createTrail(owner, 'Tokyo eats')
  await request(owner, 'PATCH', `/trails/${trailId}`, {
    description: 'Ramen & more',
  })
  const put = await putCover(owner.token, trailId, await samplePng())
  const coverUrl = await coverUrlOf(put)

  const linkRes = await request(
    owner,
    'POST',
    `/trails/${trailId}/share-link`,
  )
  expect(linkRes.status).toBe(200)
  const token = (linkRes.json.url as string).split('/').pop()!

  const page = await app.request(`/trails/join/${token}`)
  expect(page.status).toBe(200)
  const html = await page.text()
  expect(html).toContain('Tokyo eats')
  expect(html).toContain('Ramen &amp; more')
  expect(html).toContain(coverUrl!)

  // an unknown token reveals nothing
  const unknown = await app.request(`/trails/join/${'a'.repeat(24)}`)
  expect(unknown.status).toBe(200)
  const generic = await unknown.text()
  expect(generic).not.toContain('Tokyo eats')
})
