import {
  CreateCrumbSchema,
  CreateReminderSchema,
  MapBoundsQuerySchema,
  pageQueryShape,
  UpdateCrumbSchema,
  type Reminder,
} from '@crumbs/core/schemas'
import {and, asc, eq, isNull} from 'drizzle-orm'
import {Hono} from 'hono'
import {HTTPException} from 'hono/http-exception'
import {z} from 'zod'

import {type GuardedVars} from '../auth/middleware'
import {db, schema} from '../db/client'
import {
  removeReplacedImage,
  storeUploadedImage,
} from '../lib/blob-image'
import {resolveClientIdReplay} from '../lib/idempotency'
import {crumbImageLimiter} from '../lib/rate-limit'
import {firstOr404} from '../lib/require'
import {crumbImageKey, processCrumbImage} from '../lib/source-image'
import {uuidParams, validate} from '../lib/validate'
import {
  getCrumbResponseById,
  listAuthorCrumbs,
  listMapPlaces,
  listTagCounts,
  listUpcomingCrumbs,
  loadCrumbResponsesByIds,
  requireOwnCrumb,
  requireSource,
  resolveCrumbTarget,
  suggestTags,
  syncCrumbTags,
} from '../services/crumbs'
import {listFriendIds} from '../services/friends'
import {listTrailsContainingCrumb} from '../services/trails'
import {loadUserSummaries} from '../services/users'
import {isCrumbViewable} from '../services/visibility'

type ReminderRow = typeof schema.reminders.$inferSelect

const toReminderResponse = (row: ReminderRow): Reminder => ({
  id: row.id,
  crumbId: row.crumbId,
  remindAt: row.remindAt.toISOString(),
  kind: row.kind,
})

const MAX_CRUMB_IMAGE_UPLOAD = 10 * 1024 * 1024

const crumbs = new Hono<{Variables: GuardedVars}>()
  .post('/', validate('json', CreateCrumbSchema), async (c) => {
    const user = c.get('user')
    const input = c.req.valid('json')
    const tagNames = input.tags ?? []

    // entities resolve outside the crumb tx — they're global and harmless alone
    const target = await resolveCrumbTarget(input)
    const sourceId = input.sourceId
      ? await requireSource(input.sourceId)
      : null

    const crumbId = await db.transaction(async (tx) => {
      const [crumb] = await tx
        .insert(schema.crumbs)
        .values({
          // a client-minted id makes the create idempotent under replay
          ...(input.id ? {id: input.id} : {}),
          authorId: user.id,
          name: input.name,
          note: input.note ?? null,
          placeId: target?.placeId ?? null,
          eventId: target?.eventId ?? null,
          sourceId,
          source: input.source,
        })
        .onConflictDoNothing({target: schema.crumbs.id})
        .returning({id: schema.crumbs.id})
      if (crumb) {
        await syncCrumbTags(tx, crumb.id, tagNames, {replace: false})
        return crumb.id
      }
      // the client id already exists: an idempotent replay of this author's
      // create (return it — already tagged from the first landing), or a
      // forged id we must reject, never leaking another user's crumb
      const existing = await resolveClientIdReplay(
        input.id,
        async (cid) => {
          const [row] = await tx
            .select({
              id: schema.crumbs.id,
              authorId: schema.crumbs.authorId,
            })
            .from(schema.crumbs)
            .where(eq(schema.crumbs.id, cid))
            .limit(1)
          return row
        },
        (row) => row.authorId === user.id,
      )
      return existing.id
    })

    return c.json({crumb: await getCrumbResponseById(crumbId)})
  })
  .patch(
    '/:id',
    uuidParams('id'),
    validate('json', UpdateCrumbSchema),
    async (c) => {
      const user = c.get('user')
      const {id} = c.req.valid('param')
      const input = c.req.valid('json')

      // ownership first: resolving the target creates place/event rows,
      // which a non-owner must not be able to mint
      await requireOwnCrumb(id, user.id)
      const target = await resolveCrumbTarget(input)

      const updates = {
        ...(input.name !== undefined && {name: input.name}),
        ...(input.note !== undefined && {note: input.note}),
        ...(input.status !== undefined && {status: input.status}),
        ...(input.plannedFor !== undefined && {
          plannedFor: input.plannedFor
            ? new Date(input.plannedFor)
            : null,
        }),
        ...(target && {
          placeId: target.placeId,
          eventId: target.eventId,
        }),
      }

      await db.transaction(async (tx) => {
        const [crumb] = await tx
          .update(schema.crumbs)
          .set({...updates, updatedAt: new Date()})
          .where(
            and(
              eq(schema.crumbs.id, id),
              eq(schema.crumbs.authorId, user.id),
            ),
          )
          .returning({
            id: schema.crumbs.id,
            placeId: schema.crumbs.placeId,
          })
        if (!crumb)
          throw new HTTPException(404, {message: 'not_found'})
        if (input.tags !== undefined)
          await syncCrumbTags(tx, crumb.id, input.tags, {
            replace: true,
          })
        // a provisional place is named after its sole crumb; keep them in
        // step on rename (canonical places are shared and never renamed)
        if (input.name !== undefined && !target && crumb.placeId)
          await tx
            .update(schema.places)
            .set({name: input.name, updatedAt: new Date()})
            .where(
              and(
                eq(schema.places.id, crumb.placeId),
                eq(schema.places.status, 'provisional'),
              ),
            )
      })

      return c.json({crumb: await getCrumbResponseById(id)})
    },
  )
  .get(
    '/',
    validate(
      'query',
      z.object({...pageQueryShape, placeId: z.uuid().optional()}),
    ),
    async (c) => {
      const user = c.get('user')
      const {limit, cursor, placeId} = c.req.valid('query')
      const {crumbs, nextCursor} = await listAuthorCrumbs({
        authorIds: [user.id],
        limit,
        cursor,
        extraWhere: placeId
          ? eq(schema.crumbs.placeId, placeId)
          : undefined,
      })
      return c.json({crumbs, nextCursor})
    },
  )
  // everyone the viewer can see at one place: their own crumbs plus
  // accepted friends', each carrying its author's handle
  .get(
    '/at-place',
    validate(
      'query',
      z.object({...pageQueryShape, placeId: z.uuid()}),
    ),
    async (c) => {
      const user = c.get('user')
      const {limit, cursor, placeId} = c.req.valid('query')

      const authorIds = [user.id, ...(await listFriendIds(user.id))]
      const {crumbs, nextCursor} = await listAuthorCrumbs({
        authorIds,
        limit,
        cursor,
        extraWhere: eq(schema.crumbs.placeId, placeId),
      })

      const authors = await loadUserSummaries([
        ...new Set(crumbs.map((crumb) => crumb.authorId)),
      ])
      return c.json({
        crumbs: crumbs.map((crumb) => ({
          ...crumb,
          authorHandle: authors.get(crumb.authorId)?.handle ?? '',
        })),
        nextCursor,
      })
    },
  )
  .delete('/:id', uuidParams('id'), async (c) => {
    const user = c.get('user')
    const {id} = c.req.valid('param')
    firstOr404(
      await db
        .delete(schema.crumbs)
        .where(
          and(
            eq(schema.crumbs.id, id),
            eq(schema.crumbs.authorId, user.id),
          ),
        )
        .returning({id: schema.crumbs.id}),
    )
    return c.json({ok: true})
  })
  // manual per-crumb image; the server re-encodes (strips exif, neutralizes
  // payloads) so the uploaded bytes are never trusted or served as-is
  .put(
    '/:id/image',
    crumbImageLimiter,
    uuidParams('id'),
    async (c) => {
      const user = c.get('user')
      const {id} = c.req.valid('param')
      const existing = await requireOwnCrumb(id, user.id)

      const ref = await storeUploadedImage(c.req, {
        max: MAX_CRUMB_IMAGE_UPLOAD,
        process: processCrumbImage,
        key: (hash) => crumbImageKey(id, hash),
      })
      await db
        .update(schema.crumbs)
        .set({imageRef: ref, updatedAt: new Date()})
        .where(eq(schema.crumbs.id, id))

      await removeReplacedImage(existing.imageRef, ref)
      return c.json({crumb: await getCrumbResponseById(id)})
    },
  )
  .delete('/:id/image', uuidParams('id'), async (c) => {
    const user = c.get('user')
    const {id} = c.req.valid('param')
    const existing = await requireOwnCrumb(id, user.id)

    await db
      .update(schema.crumbs)
      .set({imageRef: null, updatedAt: new Date()})
      .where(eq(schema.crumbs.id, id))

    await removeReplacedImage(existing.imageRef, null)
    return c.json({crumb: await getCrumbResponseById(id)})
  })
  .get('/tags', async (c) => {
    const user = c.get('user')
    return c.json({tags: await listTagCounts(user.id)})
  })
  .get(
    '/tag-suggestions',
    validate(
      'query',
      z.object({category: z.string().max(80).optional()}),
    ),
    async (c) => {
      const user = c.get('user')
      const {category} = c.req.valid('query')
      return c.json(await suggestTags(user.id, category))
    },
  )
  // viewport-scoped pins: the viewer's crumbs aggregated to one point per
  // place inside the bbox
  .get('/map', validate('query', MapBoundsQuerySchema), async (c) => {
    const user = c.get('user')
    const {bbox, tag, status} = c.req.valid('query')
    const {places, truncated} = await listMapPlaces(user.id, bbox, {
      tag,
      status,
    })
    return c.json({places, truncated})
  })
  .get('/planned', async (c) => {
    const user = c.get('user')
    return c.json({crumbs: await listUpcomingCrumbs(user.id)})
  })
  // after /tags, /tag-suggestions, /map and /planned so they aren't
  // shadowed by /:id
  .get('/:id', uuidParams('id'), async (c) => {
    const user = c.get('user')
    const {id} = c.req.valid('param')
    const crumb = (await loadCrumbResponsesByIds([id])).get(id)
    if (!crumb) throw new HTTPException(404, {message: 'not_found'})
    // deliberate 403-vs-404 split: friend-gated resources 403 not_friends
    // so the client can drive add-friend ui, while private resources 404
    // to avoid existence leaks (cf. requireTrailMember)
    if (!(await isCrumbViewable(user.id, crumb)))
      throw new HTTPException(403, {message: 'not_friends'})
    const [authors, trails] = await Promise.all([
      loadUserSummaries([crumb.authorId]),
      listTrailsContainingCrumb(user.id, crumb.id),
    ])
    return c.json({
      crumb,
      author: authors.get(crumb.authorId) ?? null,
      trails,
    })
  })
  .post(
    '/:id/reminders',
    uuidParams('id'),
    validate('json', CreateReminderSchema),
    async (c) => {
      const user = c.get('user')
      const {id} = c.req.valid('param')
      await requireOwnCrumb(id, user.id)
      const remindAt = new Date(c.req.valid('json').remindAt)
      if (remindAt.getTime() <= Date.now())
        throw new HTTPException(400, {
          message: 'remind_at_in_past',
        })
      const [reminder] = await db
        .insert(schema.reminders)
        .values({userId: user.id, crumbId: id, remindAt})
        .returning()
      if (!reminder) throw new Error('failed to create reminder')
      return c.json({reminder: toReminderResponse(reminder)})
    },
  )
  .get('/:id/reminders', uuidParams('id'), async (c) => {
    const user = c.get('user')
    const {id} = c.req.valid('param')
    await requireOwnCrumb(id, user.id)
    const rows = await db
      .select()
      .from(schema.reminders)
      .where(
        and(
          eq(schema.reminders.crumbId, id),
          eq(schema.reminders.userId, user.id),
          isNull(schema.reminders.sentAt),
        ),
      )
      .orderBy(asc(schema.reminders.remindAt))
    return c.json({reminders: rows.map(toReminderResponse)})
  })
  .delete(
    '/:id/reminders/:reminderId',
    uuidParams('id', 'reminderId'),
    async (c) => {
      const user = c.get('user')
      const {id, reminderId} = c.req.valid('param')
      firstOr404(
        await db
          .delete(schema.reminders)
          .where(
            and(
              eq(schema.reminders.id, reminderId),
              eq(schema.reminders.crumbId, id),
              eq(schema.reminders.userId, user.id),
            ),
          )
          .returning({id: schema.reminders.id}),
      )
      return c.json({ok: true})
    },
  )

export default crumbs
