import {findById} from '@crumbs/core/collections'
import {
  TrailDetailSchema,
  TrailSchema,
  type Trail,
  type TrailDetail,
} from '@crumbs/core/schemas'
import {create} from 'zustand'

import {createSlotStore, slotHooks} from '@/lib/async-slot'
import {trailDropped, trailPatched} from '@/lib/entity-sync'
import {tolerateStatus, unwrap} from '@/lib/errors'
import {createKeyedPagedStore} from '@/lib/keyed-paged-resource'
import {
  emptyKeyedDetail,
  keyedDetailHooks,
  loadKeyed,
  patchData,
  persistKeyedDetail,
  setDetail,
  type KeyedDetail,
} from '@/lib/keyed-resource'
import {
  optimisticTrail,
  optimisticTrailDetail,
} from '@/lib/optimistic-trail'
import {
  imageOutboxHandler,
  stageImage,
  type ImageAction,
} from '@/lib/outbox-image'
import {
  createPagedStore,
  pagedHooks,
  pageParams,
} from '@/lib/paged-resource'
import {rpc} from '@/lib/rpc'
import {fromSchema} from '@/lib/snapshot-cache'
import {
  keyedDetailSnapshot,
  listSnapshot,
  pagedSnapshot,
} from '@/lib/snapshots'
import {keyedStaleGuard} from '@/lib/stale-guard'
import {removeTrailCover, uploadTrailCover} from '@/lib/trail-cover'
import {uuid} from '@/lib/uuid'
import {authorOf, requireUser} from '@/stores/auth'
import {refreshFeed} from '@/stores/feed'
import {
  afterFlush,
  defineMutation,
  discardQueuedCreate,
  shouldReconcile,
} from '@/stores/outbox'

export type {Trail, TrailDetail}

// the trails index: cursor-paged, newest first
export const useTrailsListStore = createPagedStore<Trail>(
  async (cursor) => {
    const {trails, nextCursor} = await unwrap(
      rpc.trails.$get({
        query: pageParams(cursor),
      }),
    )
    return {items: trails, nextCursor}
  },
  pagedSnapshot('trails-list', 1, fromSchema(TrailSchema)),
)

// bounded pinned/shared strip for home quick-access
const useQuickTrailsStore = createSlotStore<Trail[]>(
  [],
  async () => {
    const {trails} = await unwrap(rpc.trails.quick.$get())
    return trails
  },
  listSnapshot('trails-quick', 1, fromSchema(TrailSchema)),
)

// trails to file a crumb into: keyed by crumb so each carries its own
// containsCrumb flag, paged like the index
const addToTrail = createKeyedPagedStore<Trail>(
  (crumbId) => async (cursor) => {
    const {trails, nextCursor} = await unwrap(
      rpc.trails.$get({
        query: {crumbId, ...pageParams(cursor)},
      }),
    )
    return {items: trails, nextCursor}
  },
)

// patch a summary in the index list in place (crumb count, pin state) so the
// list reflects a mutation without a full refetch, then broadcast the patch
// to every other store holding a copy (feed, profile lists)
export const patchSummary = (
  trailId: string,
  fn: (t: Trail) => Trail,
): void => {
  const {items, replace} = useTrailsListStore.getState()
  const existing = findById(items, trailId)
  if (existing) replace(trailId, fn(existing))
  trailPatched.publish({id: trailId, patch: fn})
}

// outbox kinds, all scoped by trail id so a trail's create → update → delete
// replay in that order; different trails replay in parallel. posts, membership
// and reactions live in @/stores/trail-posts and @/stores/trail-crumbs;
// invites, joins and share links stay online — they need the server or
// another user
export const TRAIL_CREATE = 'trail.create'
const TRAIL_UPDATE = 'trail.update'
const TRAIL_DELETE = 'trail.delete'
const TRAIL_COVER = 'trail.cover'
// pin is per-user trail state; scoped by trail id so pinning a trail created
// offline replays after its create
const TRAIL_PIN = 'trail.pin'

type TrailsState = KeyedDetail<TrailDetail> & {
  loadDetail: (id: string) => Promise<void>
  create: (name: string, crumbId?: string) => Promise<Trail>
  update: (
    trailId: string,
    fields: {name?: string; description?: string},
  ) => Promise<void>
  uploadCover: (trailId: string, uri: string) => Promise<void>
  removeCover: (trailId: string) => Promise<void>
  remove: (id: string) => Promise<void>
  invite: (trailId: string, inviteeId: string) => Promise<void>
  leave: (trailId: string) => Promise<void>
  shareLink: (trailId: string) => Promise<string>
  join: (token: string) => Promise<string>
  setPinned: (trailId: string, pinned: boolean) => Promise<void>
}

const detailGuard = keyedStaleGuard()
const detailSnap = keyedDetailSnapshot(
  'trail-detail',
  1,
  fromSchema(TrailDetailSchema),
)

const useTrailsStore = create<TrailsState>((set) => {
  return {
    ...emptyKeyedDetail,
    byId: detailSnap.seed(),
    loadDetail: (id) =>
      loadKeyed(set, detailGuard, id, async () => {
        const {trail} = await unwrap(
          rpc.trails[':id'].$get({param: {id}}),
        )
        return trail
      }),
    create: async (name, crumbId) => {
      // creating a trail *from a crumb* is a crumb-in-trail membership action:
      // it needs the crumb server-side, so it stays online like the other
      // add-to-trail actions. a plain new trail is offline-capable
      if (crumbId) {
        const {trail} = await unwrap(
          rpc.trails.$post({json: {name, crumbId}}),
        )
        useTrailsListStore.getState().prepend(trail)
        void refreshFeed()
        return trail
      }
      const user = requireUser()
      // client-mint the id so the optimistic trail and the queued create share
      // it: a replayed create upserts on this pk, so a lost ack never doubles.
      // the trail shows and opens at once (offline too); the outbox replays it
      const id = uuid()
      const meta = {
        id,
        author: authorOf(user),
        now: new Date().toISOString(),
      }
      const trail = optimisticTrail(name, meta)
      useTrailsListStore.getState().prepend(trail)
      set((s) => ({
        byId: setDetail(
          s.byId,
          id,
          optimisticTrailDetail(name, meta),
        ),
      }))
      queueTrailCreate({
        scope: id,
        id,
        label: name,
        payload: {id, name},
      })
      return trail
    },
    update: async (trailId, fields) => {
      // apply optimistically, then queue the patch — the edit shows at once
      // and syncs when the network allows
      patchTrail(trailId, {
        ...(fields.name !== undefined && {name: fields.name}),
        // mirrors the server: an empty description clears it
        ...(fields.description !== undefined && {
          description: fields.description.trim() || null,
        }),
      })
      queueTrailUpdate({
        scope: trailId,
        // a description-only edit still deserves a named row
        label: fields.name ?? trailName(trailId),
        payload: {id: trailId, fields},
      })
    },
    uploadCover: async (trailId, uri) => {
      // stage a durable copy, show it at once, and queue the upload in the
      // trail's scope so it replays after the trail's own create
      const imageId = uuid()
      const staged = await stageImage(imageId, uri)
      patchTrail(trailId, {coverImageUrl: staged})
      queueTrailCover({
        scope: trailId,
        id: imageId,
        payload: {trailId, action: 'set', imageId},
      })
    },
    removeCover: async (trailId) => {
      patchTrail(trailId, {coverImageUrl: null})
      queueTrailCover({
        scope: trailId,
        payload: {trailId, action: 'remove'},
      })
    },
    remove: async (id) => {
      const name = trailName(id)
      // drop everywhere at once, then queue the delete; a replay that 404s
      // means it's already gone (success), so it's never resurrected
      dropTrail(id)
      if (discardQueuedCreate(TRAIL_CREATE, id)) return
      queueTrailDelete({scope: id, label: name, payload: {id}})
    },
    invite: async (trailId, inviteeId) => {
      await unwrap(
        rpc.trails[':id'].invites.$post({
          param: {id: trailId},
          json: {inviteeId},
        }),
      )
    },
    leave: async (trailId) => {
      await unwrap(
        rpc.trails[':id'].collaborators.me.$delete({
          param: {id: trailId},
        }),
      )
      dropTrail(trailId)
      // online-only action, already server-committed: resync the strip now
      void useQuickTrailsStore.getState().refresh()
    },
    shareLink: async (trailId) => {
      const {url} = await unwrap(
        rpc.trails[':id']['share-link'].$post({param: {id: trailId}}),
      )
      return url
    },
    join: async (token) => {
      const {trailId} = await unwrap(
        rpc.trails.join[':token'].$post({param: {token}}),
      )
      await refreshTrails()
      return trailId
    },
    setPinned: async (trailId, pinned) => {
      // pinned state is per-user and idempotent (upsert pinnedAt / clear it),
      // so it needs no client id. scoped by trail id so pinning a trail made
      // offline replays after its create. patchTrail flips it on the detail +
      // list at once; the quick strip is server-derived and resyncs on ack
      patchTrail(trailId, {pinned})
      queueTrailPin({
        scope: trailId,
        label: trailName(trailId),
        payload: {trailId, pinned},
      })
    },
  }
})

persistKeyedDetail(useTrailsStore, detailSnap, detailGuard)

// the loaded detail for one trail, for sibling stores composing on top of the
// detail cache (posts, membership) without owning it
export const getTrailDetail = (
  trailId: string,
): TrailDetail | undefined =>
  useTrailsStore.getState().byId[trailId]?.data

export const loadTrailDetail = (id: string): Promise<void> =>
  useTrailsStore.getState().loadDetail(id)

// patch one loaded trail detail in place (no-op when it isn't loaded)
export const patchTrailDetail = (
  trailId: string,
  fn: (d: TrailDetail) => TrailDetail,
): void =>
  useTrailsStore.setState((s) => ({
    byId: patchData(s.byId, trailId, fn),
  }))

// patch every loaded trail detail, preserving reference identity for
// untouched slots — so nothing re-renders or re-persists needlessly. a patch
// returns its input unchanged to leave a slot alone
export const patchTrailDetails = (
  patch: (d: TrailDetail) => TrailDetail,
): void =>
  useTrailsStore.setState((s) => {
    let changed = false
    const byId = Object.fromEntries(
      Object.entries(s.byId).map(([id, slot]) => {
        if (!slot.data) return [id, slot]
        const data = patch(slot.data)
        if (data === slot.data) return [id, slot]
        changed = true
        return [id, {...slot, data}]
      }),
    )
    return changed ? {byId} : s
  })

// merge a summary-level patch into the loaded detail and every store holding
// a summary copy. the quick strip is server-derived and deliberately not
// refreshed here — at optimistic time the refresh would race the unsynced
// mutation (offline it just errors); the handlers resync it on ack
const patchTrail = (trailId: string, patch: Partial<Trail>): void => {
  patchTrailDetail(trailId, (d) => ({...d, ...patch}))
  patchSummary(trailId, (t) => ({...t, ...patch}))
}

// the trail's display name for a queued entry's label (detail, then list)
export const trailName = (trailId: string): string | undefined =>
  getTrailDetail(trailId)?.name ??
  findById(useTrailsListStore.getState().items, trailId)?.name

// merge the server copy of a summary into the list and any loaded detail, then
// broadcast it — the create reconcile (optimistic → canonical), mirroring
// applyCrumb. keeps the loaded detail's crumbs/posts/collaborators, replacing
// only the summary fields
const applyTrailSummary = (
  trail: Trail,
  insertIfMissing = false,
): void => {
  const list = useTrailsListStore.getState()
  if (findById(list.items, trail.id)) list.replace(trail.id, trail)
  else if (insertIfMissing) list.prepend(trail)
  patchTrailDetail(trail.id, (d) => ({...d, ...trail}))
  trailPatched.publish({
    id: trail.id,
    patch: (t) => ({...t, ...trail}),
  })
  afterFlush(
    'quick',
    () => void useQuickTrailsStore.getState().refresh(),
  )
}

// drop a trail from every holder — the optimistic delete/leave, and the
// rollback for a create that can never sync. sibling stores holding per-trail
// state (post pages) drop theirs via the trailDropped channel
const dropTrail = (id: string): void => {
  // fence an in-flight loadDetail: without the claim its late resolve would
  // re-create (and re-persist) the deleted slot
  detailGuard.claim(id)
  useTrailsListStore.getState().removeItem(id)
  useTrailsStore.setState((s) => {
    const {[id]: _removed, ...byId} = s.byId
    return {byId}
  })
  trailDropped.publish(id)
}

// rollback-by-refetch, mirroring restoreCrumb: a discarded trail write leaves
// optimistic state the server never saw, so restore the server copy
// everywhere — the summary holders (stripped of detail-only fields) and the
// detail cache. a fetch failure (offline, 404) leaves things as they are —
// the next refetch heals
export const restoreTrail = (
  id: string,
  insertIfMissing = false,
): void => {
  void unwrap(rpc.trails[':id'].$get({param: {id}}))
    .then(({trail}) => {
      applyTrailSummary(TrailSchema.parse(trail), insertIfMissing)
      useTrailsStore.setState((s) => ({
        byId: setDetail(s.byId, id, trail),
      }))
    })
    .catch(() => {})
}

// the outbox replays each queued trail mutation and reconciles to the server
// truth. a create upserts on the client id (idempotent under a lost ack); an
// update/delete/post-delete that 404s means the entity is already gone —
// that's success, so the queued write is dropped, never resurrected.
// reconciles are gated on shouldReconcile: while the scope still holds queued
// followers (a rename, a pin, a cover) this response is older than their
// optimistic effects, so only the chain's last ack repaints
const queueTrailCreate = defineMutation<{id: string; name: string}>(
  TRAIL_CREATE,
  {
    run: async ({id, name}) => {
      const {trail} = await unwrap(
        rpc.trails.$post({json: {id, name}}),
      )
      if (shouldReconcile(id)) applyTrailSummary(trail, true)
      afterFlush('feed', () => void refreshFeed())
    },
    rollback: ({id}) => dropTrail(id),
    createsScope: true,
  },
)

const queueTrailUpdate = defineMutation<{
  id: string
  fields: {name?: string; description?: string}
}>(TRAIL_UPDATE, {
  run: async ({id, fields}) => {
    await tolerateStatus(404, async () => {
      const {trail} = await unwrap(
        rpc.trails[':id'].$patch({param: {id}, json: fields}),
      )
      if (shouldReconcile(id)) applyTrailSummary(trail)
    })
  },
  rollback: ({id}) => restoreTrail(id),
})

const queueTrailDelete = defineMutation<{id: string}>(TRAIL_DELETE, {
  run: async ({id}) => {
    await tolerateStatus(404, () =>
      unwrap(rpc.trails[':id'].$delete({param: {id}})),
    )
    afterFlush(
      'quick',
      () => void useQuickTrailsStore.getState().refresh(),
    )
  },
  // the trail is still alive server-side; bring it back into the lists
  rollback: ({id}) => restoreTrail(id, true),
})

// upload the staged cover (or clear it) once the trail exists; the shared
// handler reconciles to the server's content-addressed url and frees the
// local copy. a discarded remove restores the server copy
const queueTrailCover = defineMutation<
  {trailId: string} & ImageAction
>(
  TRAIL_COVER,
  imageOutboxHandler({
    entityId: (payload) => payload.trailId,
    upload: uploadTrailCover,
    remove: removeTrailCover,
    shouldReconcile,
    reconcile: (trail) => applyTrailSummary(trail),
    clearOptimistic: (trailId, stagedUri) => {
      patchTrailDetail(trailId, (d) =>
        d.coverImageUrl === stagedUri
          ? {...d, coverImageUrl: null}
          : d,
      )
      patchSummary(trailId, (t) =>
        t.coverImageUrl === stagedUri
          ? {...t, coverImageUrl: null}
          : t,
      )
    },
    restore: (trailId) => restoreTrail(trailId),
  }),
)

// replay the pin toggle (idempotent upsert / clear), then resync the quick
// strip (server-derived). a 404 means the trail is gone — drop the write
const queueTrailPin = defineMutation<{
  trailId: string
  pinned: boolean
}>(TRAIL_PIN, {
  run: async ({trailId, pinned}) => {
    await tolerateStatus(404, () =>
      unwrap(
        pinned
          ? rpc.trails[':id'].pin.$put({param: {id: trailId}})
          : rpc.trails[':id'].pin.$delete({param: {id: trailId}}),
      ),
    )
    afterFlush(
      'quick',
      () => void useQuickTrailsStore.getState().refresh(),
    )
  },
  rollback: ({trailId}) => restoreTrail(trailId),
})

// the add-to-trail sheets hold trail summaries too: keep them in step with
// trail edits and deletes like every other holder
trailPatched.subscribe(({id, patch}) =>
  addToTrail.mapItems((t) => (t.id === id ? patch(t) : t)),
)
trailDropped.subscribe((id) =>
  addToTrail.removeItem((t) => t.id === id),
)

export const trailsList = pagedHooks(useTrailsListStore)
export const {
  useItems: useTrails,
  useLoading: useTrailsLoading,
  useLoadingMore: useTrailsLoadingMore,
  useReachedEnd: useTrailsReachedEnd,
  useError: useTrailsError,
  useLoad: useLoadTrails,
  useLoadMore: useLoadMoreTrails,
} = trailsList

export const {
  useData: useQuickTrails,
  useRefresh: useRefreshQuickTrails,
} = slotHooks(useQuickTrailsStore)

export const addToTrailList = addToTrail

export const {
  useData: useTrail,
  useLoading: useTrailDetailLoading,
  useError: useTrailDetailError,
} = keyedDetailHooks(useTrailsStore)
export const useLoadTrailDetail = () =>
  useTrailsStore((s) => s.loadDetail)

// the detail-screen face of the trail cache (for @/hooks/use-detail)
export const trailDetail = {
  useData: useTrail,
  useLoading: useTrailDetailLoading,
  useError: useTrailDetailError,
  useLoad: useLoadTrailDetail,
}
export const useCreateTrail = () => useTrailsStore((s) => s.create)
export const useUpdateTrail = () => useTrailsStore((s) => s.update)
export const useUploadTrailCover = () =>
  useTrailsStore((s) => s.uploadCover)
export const useRemoveTrailCover = () =>
  useTrailsStore((s) => s.removeCover)
export const useRemoveTrail = () => useTrailsStore((s) => s.remove)
export const useInviteToTrail = () => useTrailsStore((s) => s.invite)
export const useLeaveTrail = () => useTrailsStore((s) => s.leave)
export const useTrailShareLink = () =>
  useTrailsStore((s) => s.shareLink)
export const useJoinTrail = () => useTrailsStore((s) => s.join)
export const useSetTrailPinned = () =>
  useTrailsStore((s) => s.setPinned)

// resync both the index and the quick strip after a membership change
export const refreshTrails = () =>
  Promise.all([
    useTrailsListStore.getState().load(),
    useQuickTrailsStore.getState().refresh(),
  ])
