import {
  findById,
  moveItem,
  patchById,
  removeById,
} from '@crumbs/core/collections'
import {type Crumb, type TrailDetail} from '@crumbs/core/schemas'

import {crumbDropped, crumbSynced} from '@/lib/entity-sync'
import {isApiStatus, tolerateStatus, unwrap} from '@/lib/errors'
import {applyMyReaction} from '@/lib/reactions'
import {rpc} from '@/lib/rpc'
import {getCrumbById} from '@/stores/crumbs'
import {
  afterFlush,
  defineMutation,
  hasQueuedMutation,
} from '@/stores/outbox'
import {
  getTrailDetail,
  loadTrailDetail,
  patchSummary,
  patchTrailDetail,
  patchTrailDetails,
  restoreTrail,
  TRAIL_CREATE,
  trailName,
} from '@/stores/trails'

// crumb-in-trail membership (add / remove / reorder) and per-crumb reactions
// within a trail

// membership kinds are scoped by trail id so they serialise with each other
// and the trail's create (a reorder lands after its adds). adding a *synced*
// crumb works offline; adding one still queued for create can race its create
// and dead-letter (redrivable once the crumb syncs)
const TRAIL_CRUMB_ADD = 'trail.crumb.add'
const TRAIL_CRUMB_REMOVE = 'trail.crumb.remove'
const TRAIL_CRUMB_MOVE = 'trail.crumb.move'
// reactions are per (trail, crumb, user) — scoped by target, not the trail, so
// a stuck trail write never blocks reacting and different crumbs stay parallel
const TRAIL_REACT = 'trail.react'

// a crumb's display name as this trail knows it (detail, then own crumbs)
const crumbName = (
  trailId: string,
  crumbId: string,
): string | undefined =>
  findById(getTrailDetail(trailId)?.crumbs ?? [], crumbId)?.name ??
  getCrumbById(crumbId)?.name

// optimistically move the viewer's reaction on one crumb within a loaded trail
// detail; the aggregate maths lives in the pure applyMyReaction
const patchCrumbReaction = (
  trailId: string,
  crumbId: string,
  emoji: string | null,
): void =>
  patchTrailDetail(trailId, (d) => ({
    ...d,
    crumbs: d.crumbs.map((c) =>
      c.id === crumbId
        ? {...c, reactions: applyMyReaction(c.reactions, emoji)}
        : c,
    ),
  }))

// optimistically file a crumb into a loaded trail detail + bump the count on
// the detail and the list summary. the crumb is looked up by id (the detail
// cache holds any crumb you've viewed, own or a friend's); if it isn't held
// or the detail isn't loaded, the count still bumps and the crumb fills in on
// the next loadDetail
const addCrumbToDetail = (trailId: string, crumbId: string): void => {
  const detail = getTrailDetail(trailId)
  if (detail && findById(detail.crumbs, crumbId)) return // already filed
  const crumb: Crumb | undefined = getCrumbById(crumbId)
  patchTrailDetail(trailId, (d) => ({
    ...d,
    crumbs: crumb
      ? [...d.crumbs, {...crumb, reactions: []}]
      : d.crumbs,
    crumbCount: d.crumbCount + 1,
  }))
  patchSummary(trailId, (t) => ({...t, crumbCount: t.crumbCount + 1}))
}

const removeCrumbFromDetail = (
  trailId: string,
  crumbId: string,
): void => {
  const detail = getTrailDetail(trailId)
  if (detail && !findById(detail.crumbs, crumbId)) return // not filed
  patchTrailDetail(trailId, (d) => ({
    ...d,
    crumbs: removeById(d.crumbs, crumbId),
    crumbCount: Math.max(0, d.crumbCount - 1),
  }))
  patchSummary(trailId, (t) => ({
    ...t,
    crumbCount: Math.max(0, t.crumbCount - 1),
  }))
}

export const addCrumbToTrail = async (
  trailId: string,
  crumbId: string,
): Promise<void> => {
  // show it in the trail + bump the count at once, then queue the add. the
  // (trail, crumb) pk dedupes a replay (409 already_in_trail = done)
  addCrumbToDetail(trailId, crumbId)
  queueTrailCrumbAdd({
    scope: trailId,
    label: crumbName(trailId, crumbId) ?? trailName(trailId),
    payload: {trailId, crumbId},
  })
}

export const removeCrumbFromTrail = async (
  trailId: string,
  crumbId: string,
): Promise<void> => {
  const label = crumbName(trailId, crumbId) ?? trailName(trailId)
  removeCrumbFromDetail(trailId, crumbId)
  queueTrailCrumbRemove({
    scope: trailId,
    label,
    payload: {trailId, crumbId},
  })
}

export const moveTrailCrumb = async (
  trailId: string,
  crumbId: string,
  position: number,
): Promise<void> => {
  // the drag already settled visually; reorder the local detail and queue
  // the move. fifo within the trail scope keeps the position index valid
  // on replay (adds/moves land in the order they were made)
  patchTrailDetail(trailId, (d) => {
    const from = d.crumbs.findIndex((c) => c.id === crumbId)
    return from === -1
      ? d
      : {...d, crumbs: moveItem(d.crumbs, from, position)}
  })
  queueTrailCrumbMove({
    scope: trailId,
    label: trailName(trailId),
    payload: {trailId, crumbId, position},
  })
}

export const reactToCrumb = async (
  trailId: string,
  crumbId: string,
  emoji: string | null,
): Promise<void> => {
  // apply to the local aggregate at once, then queue it. the endpoint
  // upserts / idempotent-deletes by the (trail, crumb, user) pk, so a
  // replay needs no client id. scoped per target so a react → unreact on
  // one crumb serialises while different crumbs replay in parallel
  patchCrumbReaction(trailId, crumbId, emoji)
  queueTrailReact({
    scope: `${trailId}:${crumbId}`,
    label: crumbName(trailId, crumbId),
    payload: {trailId, crumbId, emoji},
  })
}

// crumb-in-trail membership. add: the (trail, crumb) pk dedupes a replay —
// 409 already_in_trail means it's already filed = done. remove / move that 404
// mean the crumb is already out or the trail is gone = done. add reconciles the
// detail (canonical crumb data + position); remove/move trust the optimistic
// state, which fifo replay keeps in step with the server
const queueTrailCrumbAdd = defineMutation<{
  trailId: string
  crumbId: string
}>(TRAIL_CRUMB_ADD, {
  run: async ({trailId, crumbId}) => {
    const added = await tolerateStatus(409, () =>
      unwrap(
        rpc.trails[':id'].crumbs.$post({
          param: {id: trailId},
          json: {crumbId},
        }),
      ),
    )
    // 409 already_in_trail: already filed, the detail needs no backfill
    if (added)
      afterFlush(
        `detail:${trailId}`,
        () => void loadTrailDetail(trailId),
      )
  },
  // a discarded add never landed: take the crumb back out of the detail and
  // the summary counts
  rollback: ({trailId, crumbId}) =>
    removeCrumbFromDetail(trailId, crumbId),
})

const queueTrailCrumbRemove = defineMutation<{
  trailId: string
  crumbId: string
}>(TRAIL_CRUMB_REMOVE, {
  run: async ({trailId, crumbId}) => {
    await tolerateStatus(404, () =>
      unwrap(
        rpc.trails[':id'].crumbs[':crumbId'].$delete({
          param: {id: trailId, crumbId},
        }),
      ),
    )
  },
  // the membership is still alive server-side; the refetch restores it
  rollback: ({trailId}) => restoreTrail(trailId),
})

const queueTrailCrumbMove = defineMutation<{
  trailId: string
  crumbId: string
  position: number
}>(TRAIL_CRUMB_MOVE, {
  run: async ({trailId, crumbId, position}) => {
    await tolerateStatus(404, () =>
      unwrap(
        rpc.trails[':id'].crumbs[':crumbId'].position.$put({
          param: {id: trailId, crumbId},
          json: {position},
        }),
      ),
    )
  },
  // the server never saw the reorder; the refetch restores its order
  rollback: ({trailId}) => restoreTrail(trailId),
})

// replay the reaction (idempotent upsert / delete by pk), then reconcile the
// true aggregate — others may have reacted while offline. a 404 means the trail
// or crumb is gone, so the reaction is moot and the queued write is dropped
const queueTrailReact = defineMutation<{
  trailId: string
  crumbId: string
  emoji: string | null
}>(TRAIL_REACT, {
  run: async ({trailId, crumbId, emoji}) => {
    const route = rpc.trails[':id'].crumbs[':crumbId'].reactions
    try {
      await unwrap(
        emoji
          ? route.$put({param: {id: trailId, crumbId}, json: {emoji}})
          : route.$delete({param: {id: trailId, crumbId}}),
      )
    } catch (err) {
      if (!isApiStatus(err, 404)) throw err
      // a reaction racing its trail's queued create (or the crumb's queued
      // add) sees a transient 404, not a gone target — retry, don't drop
      if (
        hasQueuedMutation(TRAIL_CREATE, trailId) ||
        hasQueuedMutation(TRAIL_CRUMB_ADD, trailId)
      )
        throw err
    }
    // reconcile the true aggregate once per trail when the drain settles
    afterFlush(
      `detail:${trailId}`,
      () => void loadTrailDetail(trailId),
    )
  },
})

// keep crumb copies inside loaded trail details in step with crumb mutations.
// only slots actually holding the crumb are rebuilt — untouched details keep
// reference identity, so nothing re-renders or re-persists needlessly
const patchDetailCrumbs = (
  crumbId: string,
  patch: (crumbs: TrailDetail['crumbs']) => TrailDetail['crumbs'],
) =>
  patchTrailDetails((d) =>
    findById(d.crumbs, crumbId) ? {...d, crumbs: patch(d.crumbs)} : d,
  )

crumbSynced.subscribe((crumb) =>
  patchDetailCrumbs(crumb.id, (crumbs) =>
    patchById(crumbs, crumb.id, (c) => ({...c, ...crumb})),
  ),
)
crumbDropped.subscribe((id) =>
  patchDetailCrumbs(id, (crumbs) => removeById(crumbs, id)),
)
